@bitkyc08/opencodex 2.6.26-preview.20260705 → 2.6.28-preview.20260707
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 +1 -0
- package/bin/ocx.mjs +4 -4
- package/gui/dist/assets/index-ByGC8-Bm.css +1 -0
- package/gui/dist/assets/index-CkV5xFA8.js +15 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/adapters/anthropic-image-guard.ts +195 -0
- package/src/adapters/anthropic.ts +85 -14
- package/src/adapters/cursor/cursor-errors.ts +2 -2
- package/src/adapters/cursor/live-transport.ts +1 -1
- package/src/adapters/cursor/transport-retry.ts +2 -2
- package/src/adapters/google-errors.ts +1 -1
- package/src/adapters/google-http.ts +1 -1
- package/src/adapters/google-truncation.ts +1 -1
- package/src/adapters/google.ts +1 -1
- package/src/adapters/kiro-errors.ts +1 -1
- package/src/adapters/kiro-retry.ts +1 -1
- package/src/adapters/kiro-truncation.ts +1 -1
- package/src/adapters/kiro.ts +1 -1
- package/src/adapters/openai-chat.ts +12 -2
- package/src/adapters/openai-responses.ts +126 -3
- package/src/bridge.ts +164 -7
- package/src/{doctor.ts → cli/doctor.ts} +21 -4
- package/src/{cli-help.ts → cli/help.ts} +1 -1
- package/src/cli/index.ts +584 -0
- package/src/{init.ts → cli/init.ts} +6 -6
- package/src/{cli-models.ts → cli/models.ts} +2 -2
- package/src/{cli-provider.ts → cli/provider.ts} +12 -8
- package/src/{star-prompt.ts → cli/star-prompt.ts} +1 -1
- package/src/{cli-status.ts → cli/status.ts} +7 -7
- package/src/cli.ts +9 -575
- package/src/{codex-account-label.ts → codex/account-label.ts} +1 -1
- package/src/{codex-account-lifecycle.ts → codex/account-lifecycle.ts} +6 -6
- package/src/{codex-account-store.ts → codex/account-store.ts} +2 -2
- package/src/{codex-account-usability.ts → codex/account-usability.ts} +4 -4
- package/src/{codex-auth-api.ts → codex/auth-api.ts} +18 -18
- package/src/{codex-auth-collision.ts → codex/auth-collision.ts} +4 -4
- package/src/{codex-auth-context.ts → codex/auth-context.ts} +9 -9
- package/src/{codex-catalog.ts → codex/catalog.ts} +49 -20
- package/src/codex/history-migration-guardian.ts +102 -0
- package/src/{codex-history-provider.ts → codex/history-provider.ts} +111 -7
- package/src/{codex-home.ts → codex/home.ts} +1 -1
- package/src/{codex-inject.ts → codex/inject.ts} +204 -26
- package/src/{codex-journal.ts → codex/journal.ts} +2 -2
- package/src/{codex-main-account.ts → codex/main-account.ts} +2 -2
- package/src/{model-cache.ts → codex/model-cache.ts} +1 -1
- package/src/{codex-paths.ts → codex/paths.ts} +2 -2
- package/src/{codex-plugins-doctor.ts → codex/plugins-doctor.ts} +2 -2
- package/src/{codex-refresh.ts → codex/refresh.ts} +4 -4
- package/src/{codex-routing.ts → codex/routing.ts} +8 -8
- package/src/{codex-shim.ts → codex/shim.ts} +6 -5
- package/src/{codex-sync.ts → codex/sync.ts} +4 -4
- package/src/{codex-websocket-registry.ts → codex/websocket-registry.ts} +1 -1
- package/src/config.ts +2 -0
- package/src/generated/jawcode-model-metadata.ts +2 -0
- package/src/{bun-runtime.ts → lib/bun-runtime.ts} +1 -1
- package/src/{crash-guard.ts → lib/crash-guard.ts} +1 -1
- package/src/{process-control.ts → lib/process-control.ts} +1 -1
- package/src/{service-secrets.ts → lib/service-secrets.ts} +1 -1
- package/src/oauth/callback-server.ts +1 -1
- package/src/oauth/google-antigravity.ts +7 -4
- package/src/oauth/index.ts +67 -16
- package/src/oauth/login-cli.ts +2 -2
- package/src/oauth/store.ts +236 -20
- package/src/oauth/token-guardian.ts +24 -20
- package/src/oauth/types.ts +16 -0
- package/src/providers/api-keys.ts +121 -0
- package/src/{provider-context-cap.ts → providers/context-cap.ts} +1 -1
- package/src/providers/derive.ts +2 -0
- package/src/providers/key-failover.ts +145 -0
- package/src/{provider-label.ts → providers/label.ts} +1 -1
- package/src/{provider-quota.ts → providers/quota.ts} +12 -7
- package/src/providers/registry.ts +66 -4
- package/src/responses/compaction.ts +117 -0
- package/src/responses/parser.ts +89 -13
- package/src/responses/reasoning-envelope.ts +52 -0
- package/src/responses/schema.ts +15 -3
- package/src/responses/state.ts +117 -2
- package/src/router.ts +2 -0
- package/src/server/auth-cors.ts +231 -0
- package/src/server/index.ts +523 -0
- package/src/server/lifecycle.ts +73 -0
- package/src/server/management-api.ts +628 -0
- package/src/{proxy-liveness.ts → server/proxy-liveness.ts} +1 -1
- package/src/server/relay.ts +534 -0
- package/src/server/request-decompress.ts +46 -0
- package/src/server/request-log.ts +310 -0
- package/src/server/responses.ts +775 -0
- package/src/{ws-bridge.ts → server/ws-bridge.ts} +44 -13
- package/src/service.ts +19 -11
- package/src/types.ts +27 -0
- package/src/{update.ts → update/index.ts} +5 -5
- package/src/{update-job.ts → update/job.ts} +7 -6
- package/src/{update-notify.ts → update/notify.ts} +3 -3
- package/src/{usage-debug.ts → usage/debug.ts} +3 -3
- package/src/{usage-log.ts → usage/log.ts} +3 -3
- package/src/{usage-summary.ts → usage/summary.ts} +3 -3
- package/src/{usage-totals.ts → usage/totals.ts} +1 -1
- package/src/vision/describe.ts +3 -3
- package/src/vision/index.ts +21 -1
- package/src/web-search/executor.ts +4 -4
- package/src/web-search/index.ts +1 -1
- package/src/web-search/loop.ts +84 -24
- package/gui/dist/assets/index-BcHhxo1I.css +0 -1
- package/gui/dist/assets/index-DCC1q_Jx.js +0 -15
- package/src/server.ts +0 -2501
- /package/src/{codex-account-runtime-state.ts → codex/account-runtime-state.ts} +0 -0
- /package/src/{codex-quota.ts → codex/quota.ts} +0 -0
- /package/src/{abort.ts → lib/abort.ts} +0 -0
- /package/src/{debug.ts → lib/debug.ts} +0 -0
- /package/src/{errors.ts → lib/errors.ts} +0 -0
- /package/src/{open-url.ts → lib/open-url.ts} +0 -0
- /package/src/{privacy.ts → lib/privacy.ts} +0 -0
- /package/src/{redact.ts → lib/redact.ts} +0 -0
- /package/src/{sidecar-tracker.ts → lib/sidecar-tracker.ts} +0 -0
- /package/src/{upstream-retry.ts → lib/upstream-retry.ts} +0 -0
- /package/src/{win-paths.ts → lib/win-paths.ts} +0 -0
- /package/src/{ports.ts → server/ports.ts} +0 -0
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
import type { Server } from "bun";
|
|
2
|
+
import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../bridge";
|
|
3
|
+
import {
|
|
4
|
+
getConfigPath,
|
|
5
|
+
resolveEnvValue,
|
|
6
|
+
} from "../config";
|
|
7
|
+
import { parseRequest } from "../responses/parser";
|
|
8
|
+
import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../responses/compaction";
|
|
9
|
+
import { FORWARD_HEADERS } from "../adapters/openai-responses";
|
|
10
|
+
import { expandPreviousResponseInput, previousResponseConversationId, rememberResponseState } from "../responses/state";
|
|
11
|
+
import { routeModel } from "../router";
|
|
12
|
+
import { modelInList, namespacedToolName } from "../types";
|
|
13
|
+
import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types";
|
|
14
|
+
import {
|
|
15
|
+
getOAuthCredentialProjectId,
|
|
16
|
+
getValidAccessToken,
|
|
17
|
+
UnsupportedOAuthProviderError,
|
|
18
|
+
} from "../oauth";
|
|
19
|
+
import { buildWebSearchTool, planWebSearch, runWithWebSearch } from "../web-search";
|
|
20
|
+
import { describeImagesInPlace, planVisionSidecar, stripImagesInPlace } from "../vision";
|
|
21
|
+
import { createAdapterEventQueue } from "../adapters/run-turn-queue";
|
|
22
|
+
import {
|
|
23
|
+
applyCodexAuthContextToProvider,
|
|
24
|
+
CodexAccountCooldownError,
|
|
25
|
+
CodexAuthContextError,
|
|
26
|
+
CodexThreadAffinityExpiredError,
|
|
27
|
+
headersForCodexAuthContext,
|
|
28
|
+
isCodexAuthContextUsable,
|
|
29
|
+
resolveCodexAuthContext,
|
|
30
|
+
type CodexAuthContext,
|
|
31
|
+
} from "../codex/auth-context";
|
|
32
|
+
import {
|
|
33
|
+
formatCodexProviderForLog,
|
|
34
|
+
recordCodexUpstreamOutcome,
|
|
35
|
+
type CodexUpstreamOutcome,
|
|
36
|
+
} from "../codex/routing";
|
|
37
|
+
import { fetchWithResetRetry } from "../lib/upstream-retry";
|
|
38
|
+
import { isUsageDebugEnabled } from "../usage/debug";
|
|
39
|
+
import { readJsonRequestBody, UnsupportedContentEncodingError } from "./request-decompress";
|
|
40
|
+
import { resolveAdapter, resolveWireProtocolOverride } from "./adapter-resolve";
|
|
41
|
+
import { hasKeyPoolFailover, rotateKeyOn429 } from "../providers/key-failover";
|
|
42
|
+
import type { WsData } from "./ws-bridge";
|
|
43
|
+
import { registerTurn, trackStreamLifetime, unregisterTurn } from "./lifecycle";
|
|
44
|
+
import { redactSecretString } from "../lib/redact";
|
|
45
|
+
import {
|
|
46
|
+
catalogModelSupportsServiceTier,
|
|
47
|
+
inspectResponseLogJson,
|
|
48
|
+
readConfiguredCodexServiceTier,
|
|
49
|
+
requestLogSpeedLabel,
|
|
50
|
+
type RequestLogContext,
|
|
51
|
+
} from "./request-log";
|
|
52
|
+
import {
|
|
53
|
+
consumeForInspection,
|
|
54
|
+
consumeForResponseLogMetadata,
|
|
55
|
+
markNativePassthroughSseResponse,
|
|
56
|
+
relaySseWithFailedTail,
|
|
57
|
+
relayWithAbort,
|
|
58
|
+
sanitizePassthroughHeaders,
|
|
59
|
+
} from "./relay";
|
|
60
|
+
|
|
61
|
+
export function buildToolBridgeMaps(parsed: OcxParsedRequest): {
|
|
62
|
+
toolNsMap: Map<string, { namespace: string; name: string }>;
|
|
63
|
+
freeformToolNames: Set<string>;
|
|
64
|
+
toolSearchToolNames: Set<string>;
|
|
65
|
+
} {
|
|
66
|
+
const toolNsMap = new Map<string, { namespace: string; name: string }>();
|
|
67
|
+
const freeformToolNames = new Set<string>();
|
|
68
|
+
const toolSearchToolNames = new Set<string>();
|
|
69
|
+
for (const t of parsed.context.tools ?? []) {
|
|
70
|
+
if (t.namespace) toolNsMap.set(namespacedToolName(t.namespace, t.name), { namespace: t.namespace, name: t.name });
|
|
71
|
+
if (t.freeform) freeformToolNames.add(t.name);
|
|
72
|
+
if (t.toolSearch) toolSearchToolNames.add(t.name);
|
|
73
|
+
}
|
|
74
|
+
return { toolNsMap, freeformToolNames, toolSearchToolNames };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function sidecarOutcomeRecorder(config: OcxConfig, authCtx: CodexAuthContext): ((outcome: CodexUpstreamOutcome) => void) | undefined {
|
|
78
|
+
return authCtx.kind === "pool" || authCtx.kind === "main-pool"
|
|
79
|
+
? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome)
|
|
80
|
+
: undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Account id to attribute log labels / upstream outcomes to (pool + rotation-injected main). */
|
|
84
|
+
export function codexLogAccountId(authCtx: CodexAuthContext): string | null {
|
|
85
|
+
return authCtx.kind === "pool" || authCtx.kind === "main-pool" ? authCtx.accountId : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function usesCodexForwardPoolAuth(
|
|
89
|
+
authCtx: CodexAuthContext,
|
|
90
|
+
provider: OcxProviderConfig,
|
|
91
|
+
): authCtx is Extract<CodexAuthContext, { kind: "pool" | "main-pool" }> {
|
|
92
|
+
return (authCtx.kind === "pool" || authCtx.kind === "main-pool")
|
|
93
|
+
&& provider.authMode === "forward" && provider.adapter === "openai-responses";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function codexForwardTerminalOutcomeRecorder(
|
|
97
|
+
config: OcxConfig,
|
|
98
|
+
authCtx: CodexAuthContext,
|
|
99
|
+
provider: OcxProviderConfig,
|
|
100
|
+
): ((status: ResponsesTerminalStatus) => void) | undefined {
|
|
101
|
+
if (!usesCodexForwardPoolAuth(authCtx, provider)) return undefined;
|
|
102
|
+
return status => recordCodexUpstreamOutcome(config, authCtx.accountId, status === "completed" ? 200 : 502);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function handleResponses(
|
|
106
|
+
req: Request,
|
|
107
|
+
config: OcxConfig,
|
|
108
|
+
logCtx: RequestLogContext,
|
|
109
|
+
options: {
|
|
110
|
+
forceEmptyResponseId?: boolean;
|
|
111
|
+
abortSignal?: AbortSignal;
|
|
112
|
+
authContext?: CodexAuthContext;
|
|
113
|
+
selectedForwardHeaders?: Headers;
|
|
114
|
+
recordTerminalOutcomes?: boolean;
|
|
115
|
+
setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus) => void) | undefined) => void;
|
|
116
|
+
onNativePassthroughTerminal?: (status: ResponsesTerminalStatus) => void;
|
|
117
|
+
onNativePassthroughCancel?: () => void;
|
|
118
|
+
} = {},
|
|
119
|
+
): Promise<Response> {
|
|
120
|
+
let body: unknown;
|
|
121
|
+
try {
|
|
122
|
+
body = await readJsonRequestBody(req);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (err instanceof UnsupportedContentEncodingError) {
|
|
125
|
+
return formatErrorResponse(415, "invalid_request_error", err.message);
|
|
126
|
+
}
|
|
127
|
+
return formatErrorResponse(400, "invalid_request_error", "Invalid JSON body");
|
|
128
|
+
}
|
|
129
|
+
const originalBody = body;
|
|
130
|
+
body = expandPreviousResponseInput(body);
|
|
131
|
+
const previousResponseInputExpanded = body !== originalBody;
|
|
132
|
+
|
|
133
|
+
let parsed;
|
|
134
|
+
try {
|
|
135
|
+
parsed = parseRequest(body);
|
|
136
|
+
if (previousResponseInputExpanded) parsed._previousResponseInputExpanded = true;
|
|
137
|
+
parsed._cursorConversationId = previousResponseConversationId(parsed.previousResponseId);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return formatErrorResponse(400, "invalid_request_error", err instanceof Error ? err.message : String(err));
|
|
140
|
+
}
|
|
141
|
+
logCtx.requestedModel = parsed.modelId;
|
|
142
|
+
logCtx.requestedEffort = parsed.options.reasoning;
|
|
143
|
+
logCtx.requestedServiceTier = parsed.options.serviceTier;
|
|
144
|
+
logCtx.requestedSpeedLabel = requestLogSpeedLabel(parsed.options.serviceTier);
|
|
145
|
+
logCtx.configuredServiceTier = readConfiguredCodexServiceTier();
|
|
146
|
+
logCtx.configuredSpeedLabel = requestLogSpeedLabel(logCtx.configuredServiceTier);
|
|
147
|
+
|
|
148
|
+
let route;
|
|
149
|
+
try {
|
|
150
|
+
route = routeModel(config, parsed.modelId);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Apply the routed model id upstream: routing may strip a "<provider>/" namespace
|
|
156
|
+
// (e.g. "opencode-go/deepseek-v4-pro" → "deepseek-v4-pro"). Adapters read parsed.modelId,
|
|
157
|
+
// and the passthrough adapter serializes _rawBody, so rewrite both.
|
|
158
|
+
if (route.modelId !== parsed.modelId) {
|
|
159
|
+
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
|
160
|
+
(parsed._rawBody as { model?: string }).model = route.modelId;
|
|
161
|
+
}
|
|
162
|
+
parsed.modelId = route.modelId;
|
|
163
|
+
}
|
|
164
|
+
logCtx.model = route.modelId;
|
|
165
|
+
logCtx.provider = route.providerName;
|
|
166
|
+
logCtx.modelSupportsServiceTier = catalogModelSupportsServiceTier(
|
|
167
|
+
route.modelId,
|
|
168
|
+
logCtx.requestedServiceTier ?? logCtx.configuredServiceTier,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
let authCtx: CodexAuthContext;
|
|
172
|
+
let selectedForwardHeaders: Headers;
|
|
173
|
+
try {
|
|
174
|
+
authCtx = options.authContext ?? await resolveCodexAuthContext(req.headers, config);
|
|
175
|
+
selectedForwardHeaders = options.selectedForwardHeaders ?? headersForCodexAuthContext(req.headers, authCtx);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (err instanceof CodexAccountCooldownError) {
|
|
178
|
+
return formatErrorResponse(429, "rate_limit_error", "Selected Codex account is cooling down");
|
|
179
|
+
}
|
|
180
|
+
if (err instanceof CodexThreadAffinityExpiredError) {
|
|
181
|
+
return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
|
|
182
|
+
}
|
|
183
|
+
if (err instanceof CodexAuthContextError) {
|
|
184
|
+
const safeAccountLabel = formatCodexProviderForLog(route.providerName, err.accountId, config);
|
|
185
|
+
console.error(`[codex-auth] Pool account ${safeAccountLabel} token failed; reauthentication required`);
|
|
186
|
+
return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
|
|
187
|
+
}
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
if (!isCodexAuthContextUsable(authCtx, config)) {
|
|
191
|
+
return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
|
|
192
|
+
}
|
|
193
|
+
route.provider = applyCodexAuthContextToProvider(route.provider, authCtx);
|
|
194
|
+
logCtx.provider = formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config);
|
|
195
|
+
|
|
196
|
+
// OAuth providers: swap in a fresh access token (auto-refreshed) as the Bearer key, so the
|
|
197
|
+
// existing openai-chat / anthropic adapters authenticate with no change.
|
|
198
|
+
if (route.provider.authMode === "oauth") {
|
|
199
|
+
try {
|
|
200
|
+
route.provider = { ...route.provider, apiKey: await getValidAccessToken(route.providerName) };
|
|
201
|
+
// Antigravity (cloud-code-assist) needs the discovered Cloud Code Assist project id in the
|
|
202
|
+
// CCA envelope; the server injects only the bare token, so pull project from the credential.
|
|
203
|
+
if (route.provider.googleMode === "cloud-code-assist" && !route.provider.project) {
|
|
204
|
+
const projectId = getOAuthCredentialProjectId(route.providerName);
|
|
205
|
+
if (projectId) route.provider = { ...route.provider, project: projectId };
|
|
206
|
+
}
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if (err instanceof UnsupportedOAuthProviderError) {
|
|
209
|
+
return formatErrorResponse(
|
|
210
|
+
400,
|
|
211
|
+
"invalid_request_error",
|
|
212
|
+
`${err.message}. Remove or reconfigure provider '${route.providerName}' in ${getConfigPath()}.`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Vision sidecar: the routed model can't see images (provider.noVisionModels). Give it "eyes" —
|
|
220
|
+
// describe each attached image with a gpt vision model via the ChatGPT passthrough and replace it
|
|
221
|
+
// with text BEFORE the main call, so the text-only model can reason about it.
|
|
222
|
+
const visionPlan = planVisionSidecar(config, route.provider, route.modelId, parsed, selectedForwardHeaders, authCtx);
|
|
223
|
+
const recordSidecarOutcome = sidecarOutcomeRecorder(config, authCtx);
|
|
224
|
+
if (visionPlan) {
|
|
225
|
+
await describeImagesInPlace(parsed, visionPlan.forwardProvider, selectedForwardHeaders, visionPlan.settings, options.abortSignal, recordSidecarOutcome);
|
|
226
|
+
} else if (modelInList(route.provider.noVisionModels, route.modelId)) {
|
|
227
|
+
// Sidecar-covered model but NO plan (no forward provider / missing forwarded auth / sidecar
|
|
228
|
+
// disabled): fail closed — never forward raw images to a text-only upstream.
|
|
229
|
+
stripImagesInPlace(parsed);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider);
|
|
233
|
+
const adapter = resolveAdapter(adapterProvider, config.cacheRetention);
|
|
234
|
+
const recordTerminalOutcomes = options.recordTerminalOutcomes !== false;
|
|
235
|
+
|
|
236
|
+
// Remote compaction v2 on a ROUTED model: Codex sent `compaction_trigger` and requires exactly
|
|
237
|
+
// one `{type:"compaction"}` output item (codex-rs compact_remote_v2.rs). Passthrough handles it
|
|
238
|
+
// natively upstream; here we run the routed model as a plain summarizer — no tools, no web-search
|
|
239
|
+
// sidecar — and the bridge appends the synthetic compaction item (src/responses/compaction.ts).
|
|
240
|
+
const routedCompaction = parsed._compactionRequest === true && !("passthrough" in adapter && adapter.passthrough);
|
|
241
|
+
if (routedCompaction) {
|
|
242
|
+
delete parsed.context.tools;
|
|
243
|
+
delete parsed._webSearch;
|
|
244
|
+
delete parsed.options.toolChoice;
|
|
245
|
+
delete parsed.options.parallelToolCalls;
|
|
246
|
+
parsed.context.messages.push({ role: "user", content: COMPACT_PROMPT, timestamp: Date.now() });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if ("passthrough" in adapter && adapter.passthrough) {
|
|
250
|
+
// Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with
|
|
251
|
+
// previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex
|
|
252
|
+
// REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY
|
|
253
|
+
// way a chained turn keeps its earlier context is the local replay expansion. Record
|
|
254
|
+
// completed passthrough responses (force bypasses Codex's blanket store:false) so the next
|
|
255
|
+
// turn's expansion hits. Never record a body whose own previous_response_id failed to
|
|
256
|
+
// expand: its input is a delta, and storing it would replay a truncated conversation.
|
|
257
|
+
// Compaction turns are excluded: _rawBody still carries the full pre-compaction history and
|
|
258
|
+
// recording it would let a later expansion rehydrate the chain Codex just replaced.
|
|
259
|
+
const passthroughRecordEligible = parsed._compactionRequest !== true
|
|
260
|
+
&& (!parsed.previousResponseId || parsed._previousResponseInputExpanded === true);
|
|
261
|
+
const rememberPassthroughResponse = passthroughRecordEligible
|
|
262
|
+
? (response: { id?: unknown; output?: unknown; status?: unknown }) =>
|
|
263
|
+
rememberResponseState(parsed._rawBody, response, undefined, { force: true })
|
|
264
|
+
: undefined;
|
|
265
|
+
if (parsed.previousResponseId && !parsed._previousResponseInputExpanded) {
|
|
266
|
+
console.warn(
|
|
267
|
+
`[responses] previous_response_id ${parsed.previousResponseId} not found in local replay state `
|
|
268
|
+
+ `(model ${parsed.modelId}); forwarding without it — earlier turns may be missing from this request`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
const request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders });
|
|
272
|
+
// Abort the upstream if the client disconnects. A directly-relayed body does not propagate the
|
|
273
|
+
// consumer's cancel to a signalled fetch, so we pass the signal and relay through relayWithAbort,
|
|
274
|
+
// whose cancel() aborts the upstream — preventing leaked connections (RC2, passthrough path).
|
|
275
|
+
const upstream = new AbortController();
|
|
276
|
+
linkAbortSignal(upstream, options.abortSignal);
|
|
277
|
+
const connectMs = config.connectTimeoutMs ?? 200_000;
|
|
278
|
+
let upstreamResponse: Response;
|
|
279
|
+
try {
|
|
280
|
+
upstreamResponse = await fetchWithResetRetry(
|
|
281
|
+
() => fetchWithHeaderTimeout(request.url, {
|
|
282
|
+
method: request.method,
|
|
283
|
+
headers: request.headers,
|
|
284
|
+
body: request.body,
|
|
285
|
+
}, upstream.signal, connectMs),
|
|
286
|
+
{ abortSignal: upstream.signal, label: safeHostLabel(request.url) },
|
|
287
|
+
);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
upstream.abort();
|
|
290
|
+
const outcome = err instanceof Error && err.name === "TimeoutError" ? "timeout" : "connect_error";
|
|
291
|
+
if (usesCodexForwardPoolAuth(authCtx, route.provider)) recordCodexUpstreamOutcome(config, authCtx.accountId, outcome);
|
|
292
|
+
const msg = outcome === "timeout"
|
|
293
|
+
? `Provider connect timeout after ${connectMs}ms`
|
|
294
|
+
: `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
|
|
295
|
+
return formatErrorResponse(502, "upstream_error", msg);
|
|
296
|
+
}
|
|
297
|
+
const headers = sanitizePassthroughHeaders(upstreamResponse.headers);
|
|
298
|
+
const resolvedModel = headers.get("openai-model")?.trim();
|
|
299
|
+
if (resolvedModel) logCtx.resolvedModel = resolvedModel;
|
|
300
|
+
if (isUsageDebugEnabled()) {
|
|
301
|
+
const upstreamContentType = upstreamResponse.headers.get("content-type");
|
|
302
|
+
if (upstreamContentType) logCtx.usageDebugContentType = upstreamContentType;
|
|
303
|
+
}
|
|
304
|
+
// The chatgpt backend may omit Content-Type on SSE responses. Fall back to
|
|
305
|
+
// treating a successful body as SSE when the caller requested streaming.
|
|
306
|
+
const passthroughCt = headers.get("content-type")?.toLowerCase();
|
|
307
|
+
const isEventStream = passthroughCt?.includes("text/event-stream")
|
|
308
|
+
|| (upstreamResponse.ok && !!upstreamResponse.body && !passthroughCt && parsed.stream);
|
|
309
|
+
const terminalRecorder = codexForwardTerminalOutcomeRecorder(config, authCtx, route.provider);
|
|
310
|
+
const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream;
|
|
311
|
+
// Capture quota from upstream response for multi-account tracking
|
|
312
|
+
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
313
|
+
const weeklyRaw = upstreamResponse.headers.get("x-codex-secondary-used-percent");
|
|
314
|
+
const fiveHourRaw = upstreamResponse.headers.get("x-codex-primary-used-percent");
|
|
315
|
+
const monthlyRaw = upstreamResponse.headers.get("x-codex-tertiary-used-percent");
|
|
316
|
+
const weeklyResetRaw = upstreamResponse.headers.get("x-codex-secondary-reset-at");
|
|
317
|
+
const fiveHourResetRaw = upstreamResponse.headers.get("x-codex-primary-reset-at");
|
|
318
|
+
const monthlyResetRaw = upstreamResponse.headers.get("x-codex-tertiary-reset-at");
|
|
319
|
+
const retryAfterRaw = upstreamResponse.headers.get("retry-after");
|
|
320
|
+
if (weeklyRaw || fiveHourRaw || monthlyRaw) {
|
|
321
|
+
const { updateAccountQuota } = await import("../codex/auth-api");
|
|
322
|
+
updateAccountQuota(
|
|
323
|
+
authCtx.accountId,
|
|
324
|
+
weeklyRaw,
|
|
325
|
+
fiveHourRaw,
|
|
326
|
+
weeklyResetRaw,
|
|
327
|
+
fiveHourResetRaw,
|
|
328
|
+
monthlyRaw,
|
|
329
|
+
monthlyResetRaw,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
if (terminalBodyWillRecord) {
|
|
333
|
+
options.setTerminalOutcomeRecorder?.(status => {
|
|
334
|
+
terminalRecorder(status);
|
|
335
|
+
options.onNativePassthroughTerminal?.(status);
|
|
336
|
+
});
|
|
337
|
+
} else {
|
|
338
|
+
recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, {
|
|
339
|
+
retryAfter: retryAfterRaw,
|
|
340
|
+
resetAt: [fiveHourResetRaw, weeklyResetRaw, monthlyResetRaw],
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Bun#32111 workaround: passthrough SSE uses tee()+native relay to avoid the
|
|
346
|
+
// async-pull segfault on Windows. Branch[0] goes directly to the Response (Bun
|
|
347
|
+
// native relay, never enters JS Sink.write); branch[1] is consumed in the
|
|
348
|
+
// background for terminal-outcome/quota inspection only.
|
|
349
|
+
if (isEventStream && upstreamResponse.body) {
|
|
350
|
+
const [nativeBody, inspectBody] = upstreamResponse.body.tee();
|
|
351
|
+
const turnAc = new AbortController();
|
|
352
|
+
linkAbortSignal(upstream, turnAc.signal);
|
|
353
|
+
registerTurn(turnAc);
|
|
354
|
+
if (recordTerminalOutcomes) {
|
|
355
|
+
// A real terminal was parsed from the (teed) inspection stream — record it as the outcome
|
|
356
|
+
// even if the client has already disconnected: the turn genuinely reached that terminal, so
|
|
357
|
+
// it must log as completed/failed, not be dropped or downgraded to a cancel (#44). A pure
|
|
358
|
+
// client-cancel (no terminal seen) is finalized separately via consumeForInspection's onCancel.
|
|
359
|
+
const reportNativeTerminal = (status: ResponsesTerminalStatus) => {
|
|
360
|
+
terminalRecorder?.(status);
|
|
361
|
+
options.onNativePassthroughTerminal?.(status);
|
|
362
|
+
};
|
|
363
|
+
consumeForInspection(
|
|
364
|
+
inspectBody,
|
|
365
|
+
reportNativeTerminal,
|
|
366
|
+
turnAc.signal,
|
|
367
|
+
() => unregisterTurn(turnAc),
|
|
368
|
+
logCtx,
|
|
369
|
+
() => options.onNativePassthroughCancel?.(),
|
|
370
|
+
rememberPassthroughResponse,
|
|
371
|
+
);
|
|
372
|
+
} else {
|
|
373
|
+
consumeForResponseLogMetadata(inspectBody, logCtx, turnAc.signal, () => unregisterTurn(turnAc), rememberPassthroughResponse);
|
|
374
|
+
}
|
|
375
|
+
if (!headers.has("content-type")) headers.set("content-type", "text/event-stream");
|
|
376
|
+
// win32 must keep the pure native relay (Bun#32111 JS-sink segfault); elsewhere a JS pull
|
|
377
|
+
// relay is established practice (relayWithAbort, relaySseWithHeartbeat) and lets a
|
|
378
|
+
// mid-stream reset end with a clean response.failed terminal instead of a raw socket error.
|
|
379
|
+
const clientBody = process.platform === "win32"
|
|
380
|
+
? nativeBody
|
|
381
|
+
: relaySseWithFailedTail(nativeBody, upstream);
|
|
382
|
+
return markNativePassthroughSseResponse(new Response(clientBody, {
|
|
383
|
+
status: upstreamResponse.status,
|
|
384
|
+
headers,
|
|
385
|
+
}));
|
|
386
|
+
}
|
|
387
|
+
if (headers.get("content-type")?.toLowerCase().includes("application/json")) {
|
|
388
|
+
const text = await upstreamResponse.text();
|
|
389
|
+
inspectResponseLogJson(logCtx, text);
|
|
390
|
+
if (upstreamResponse.ok && rememberPassthroughResponse) {
|
|
391
|
+
try {
|
|
392
|
+
rememberPassthroughResponse(JSON.parse(text) as { id?: unknown; output?: unknown; status?: unknown });
|
|
393
|
+
} catch { /* non-JSON despite content-type; recording is best-effort */ }
|
|
394
|
+
}
|
|
395
|
+
return new Response(text, {
|
|
396
|
+
status: upstreamResponse.status,
|
|
397
|
+
statusText: upstreamResponse.statusText,
|
|
398
|
+
headers,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
const body = relayWithAbort(upstreamResponse.body, upstream);
|
|
402
|
+
const turnAc = new AbortController();
|
|
403
|
+
const tracked = body ? trackStreamLifetime(body, turnAc) : null;
|
|
404
|
+
return new Response(tracked, {
|
|
405
|
+
status: upstreamResponse.status,
|
|
406
|
+
headers,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (adapter.runTurn) {
|
|
411
|
+
const runTurnAbort = new AbortController();
|
|
412
|
+
linkAbortSignal(runTurnAbort, options.abortSignal);
|
|
413
|
+
const queue = createAdapterEventQueue();
|
|
414
|
+
const runTurn = async (): Promise<void> => {
|
|
415
|
+
try {
|
|
416
|
+
await adapter.runTurn?.(
|
|
417
|
+
parsed,
|
|
418
|
+
{ headers: selectedForwardHeaders, abortSignal: runTurnAbort.signal },
|
|
419
|
+
queue.push,
|
|
420
|
+
);
|
|
421
|
+
} catch (err) {
|
|
422
|
+
queue.push({
|
|
423
|
+
type: "error",
|
|
424
|
+
message: err instanceof Error ? err.message : String(err),
|
|
425
|
+
});
|
|
426
|
+
} finally {
|
|
427
|
+
queue.close();
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const { toolNsMap, freeformToolNames, toolSearchToolNames } = buildToolBridgeMaps(parsed);
|
|
432
|
+
if (parsed.stream) {
|
|
433
|
+
void runTurn();
|
|
434
|
+
const sseStream = bridgeToResponsesSSE(
|
|
435
|
+
queue.stream(), parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
|
|
436
|
+
() => {
|
|
437
|
+
runTurnAbort.abort();
|
|
438
|
+
queue.close();
|
|
439
|
+
}, 2_000,
|
|
440
|
+
{
|
|
441
|
+
...(options.forceEmptyResponseId ? { responseId: "" } : {}),
|
|
442
|
+
stallTimeoutSec: config.stallTimeoutSec,
|
|
443
|
+
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
444
|
+
...(routedCompaction ? { compaction: true } : {}),
|
|
445
|
+
...(routedCompaction ? {} : { onCompletedResponse: (response: Record<string, unknown>) => rememberResponseState(parsed._rawBody, response, parsed._cursorConversationId) }),
|
|
446
|
+
},
|
|
447
|
+
);
|
|
448
|
+
const bridgeTurnAc = new AbortController();
|
|
449
|
+
const trackedSse = trackStreamLifetime(sseStream, bridgeTurnAc);
|
|
450
|
+
return new Response(trackedSse, {
|
|
451
|
+
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" },
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
await runTurn();
|
|
456
|
+
const events = await queue.collect();
|
|
457
|
+
const json = buildResponseJSON(events, parsed.modelId, {
|
|
458
|
+
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
459
|
+
toolNsMap,
|
|
460
|
+
freeformToolNames,
|
|
461
|
+
toolSearchToolNames,
|
|
462
|
+
...(routedCompaction ? { compaction: true } : {}),
|
|
463
|
+
});
|
|
464
|
+
if (!routedCompaction) rememberResponseState(parsed._rawBody, json, parsed._cursorConversationId);
|
|
465
|
+
return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Web-search sidecar: Codex enabled web_search but this is a routed (non-OpenAI) model that can't
|
|
469
|
+
// run it server-side. Expose web_search as a function tool and run searches via the gpt-mini sidecar
|
|
470
|
+
// through the ChatGPT passthrough, looping until the model answers. Otherwise take the normal path.
|
|
471
|
+
const wsPlan = planWebSearch(config, parsed, false, selectedForwardHeaders, route.provider, route.modelId, authCtx);
|
|
472
|
+
if (wsPlan) {
|
|
473
|
+
parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()];
|
|
474
|
+
const wsResponse = await runWithWebSearch({
|
|
475
|
+
parsed, adapter,
|
|
476
|
+
forwardProvider: wsPlan.forwardProvider,
|
|
477
|
+
hostedTool: wsPlan.hostedTool,
|
|
478
|
+
selectedForwardHeaders,
|
|
479
|
+
settings: wsPlan.settings,
|
|
480
|
+
maxSearches: wsPlan.maxSearches,
|
|
481
|
+
forceEmptyResponseId: true,
|
|
482
|
+
abortSignal: options.abortSignal,
|
|
483
|
+
recordSidecarOutcome,
|
|
484
|
+
connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
|
|
485
|
+
on429: retryAfter => {
|
|
486
|
+
const rotated = rotateKeyOn429(config, route.providerName, retryAfter, Date.now(), route.provider.apiKey);
|
|
487
|
+
if (!rotated) return null;
|
|
488
|
+
route.provider = rotated;
|
|
489
|
+
return resolveAdapter(
|
|
490
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, rotated),
|
|
491
|
+
config.cacheRetention,
|
|
492
|
+
);
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
// Register the sidecar stream as an active turn so drainAndShutdown waits for (or aborts)
|
|
496
|
+
// in-flight web-search turns instead of skipping them during graceful shutdown.
|
|
497
|
+
if (wsResponse.body) {
|
|
498
|
+
const wsTurnAc = new AbortController();
|
|
499
|
+
return new Response(trackStreamLifetime(wsResponse.body, wsTurnAc), {
|
|
500
|
+
status: wsResponse.status,
|
|
501
|
+
headers: wsResponse.headers,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return wsResponse;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const upstream = new AbortController();
|
|
508
|
+
const cleanupUpstreamAbort = linkAbortSignal(upstream, options.abortSignal);
|
|
509
|
+
const connectMs = config.connectTimeoutMs ?? 200_000;
|
|
510
|
+
|
|
511
|
+
const request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders });
|
|
512
|
+
if (typeof request.usageLog?.inputTokens === "number") {
|
|
513
|
+
logCtx.usageLogInputTokens = request.usageLog.inputTokens;
|
|
514
|
+
}
|
|
515
|
+
let upstreamResponse: Response;
|
|
516
|
+
try {
|
|
517
|
+
upstreamResponse = adapter.fetchResponse
|
|
518
|
+
? await adapter.fetchResponse(request, { abortSignal: upstream.signal, timeoutMs: connectMs })
|
|
519
|
+
: await fetchWithResetRetry(
|
|
520
|
+
() => fetchWithHeaderTimeout(request.url, {
|
|
521
|
+
method: request.method, headers: request.headers, body: request.body,
|
|
522
|
+
}, upstream.signal, connectMs),
|
|
523
|
+
{ abortSignal: upstream.signal, label: safeHostLabel(request.url) },
|
|
524
|
+
);
|
|
525
|
+
} catch (err) {
|
|
526
|
+
cleanupUpstreamAbort();
|
|
527
|
+
upstream.abort();
|
|
528
|
+
const msg = err instanceof Error && err.name === "TimeoutError"
|
|
529
|
+
? `Provider connect timeout after ${connectMs}ms`
|
|
530
|
+
: `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
|
|
531
|
+
return formatErrorResponse(502, "upstream_error", msg);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (!upstreamResponse.ok) {
|
|
535
|
+
// Multi-key 429 failover: rotate to the next pool key (cooldown-aware) and retry the SAME
|
|
536
|
+
// request once per remaining key. OAuth/forward providers and single-key pools return null
|
|
537
|
+
// immediately, so this stays a no-op for them (src/providers/key-failover.ts).
|
|
538
|
+
while (upstreamResponse.status === 429 && hasKeyPoolFailover(route.provider)) {
|
|
539
|
+
const rotated = rotateKeyOn429(config, route.providerName, upstreamResponse.headers.get("retry-after"), Date.now(), route.provider.apiKey);
|
|
540
|
+
if (!rotated) break;
|
|
541
|
+
// Release the failed response's socket before retrying; unread bodies otherwise linger
|
|
542
|
+
// until runtime cleanup (one per rotated key under a rate-limit storm).
|
|
543
|
+
try { void upstreamResponse.body?.cancel(); } catch { /* already consumed/closed */ }
|
|
544
|
+
route.provider = rotated;
|
|
545
|
+
const retryAdapter = resolveAdapter(
|
|
546
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, rotated),
|
|
547
|
+
config.cacheRetention,
|
|
548
|
+
);
|
|
549
|
+
const retryRequest = await retryAdapter.buildRequest(parsed, { headers: selectedForwardHeaders });
|
|
550
|
+
try {
|
|
551
|
+
upstreamResponse = retryAdapter.fetchResponse
|
|
552
|
+
? await retryAdapter.fetchResponse(retryRequest, { abortSignal: upstream.signal, timeoutMs: connectMs })
|
|
553
|
+
: await fetchWithHeaderTimeout(retryRequest.url, {
|
|
554
|
+
method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body,
|
|
555
|
+
}, upstream.signal, connectMs);
|
|
556
|
+
} catch {
|
|
557
|
+
break; // network failure on the retry: fall through to the original error path
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (!upstreamResponse.ok) {
|
|
561
|
+
const errorText = await upstreamResponse.text().catch(() => "unknown error");
|
|
562
|
+
cleanupUpstreamAbort();
|
|
563
|
+
// Upstreams occasionally echo request details in error bodies — scrub token-shaped
|
|
564
|
+
// material before it reaches the client-facing error surface.
|
|
565
|
+
return formatErrorResponse(upstreamResponse.status, "upstream_error", `Provider error ${upstreamResponse.status}: ${redactSecretString(errorText.slice(0, 500))}`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (parsed.stream) {
|
|
570
|
+
const eventStream = adapter.parseStream(upstreamResponse);
|
|
571
|
+
const { toolNsMap, freeformToolNames, toolSearchToolNames } = buildToolBridgeMaps(parsed);
|
|
572
|
+
const sseStream = bridgeToResponsesSSE(
|
|
573
|
+
eventStream, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
|
|
574
|
+
() => upstream.abort(), 2_000,
|
|
575
|
+
{
|
|
576
|
+
...(options.forceEmptyResponseId ? { responseId: "" } : {}),
|
|
577
|
+
stallTimeoutSec: config.stallTimeoutSec,
|
|
578
|
+
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
579
|
+
...(routedCompaction ? { compaction: true } : {}),
|
|
580
|
+
// Compaction turns must NOT enter the continuation cache: _rawBody still holds the full
|
|
581
|
+
// PRE-compaction history, and a later previous_response_id expansion would rehydrate the
|
|
582
|
+
// giant stale chain Codex just replaced.
|
|
583
|
+
...(routedCompaction ? {} : { onCompletedResponse: (response: Record<string, unknown>) => rememberResponseState(parsed._rawBody, response, parsed._cursorConversationId) }),
|
|
584
|
+
},
|
|
585
|
+
);
|
|
586
|
+
const bridgeTurnAc = new AbortController();
|
|
587
|
+
const trackedSse = trackStreamLifetime(sseStream, bridgeTurnAc, cleanupUpstreamAbort);
|
|
588
|
+
return new Response(trackedSse, {
|
|
589
|
+
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" },
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (adapter.parseResponse) {
|
|
594
|
+
let events: AdapterEvent[];
|
|
595
|
+
try {
|
|
596
|
+
events = await adapter.parseResponse(upstreamResponse);
|
|
597
|
+
} finally {
|
|
598
|
+
cleanupUpstreamAbort();
|
|
599
|
+
}
|
|
600
|
+
const { toolNsMap, freeformToolNames, toolSearchToolNames } = buildToolBridgeMaps(parsed);
|
|
601
|
+
const json = buildResponseJSON(events, parsed.modelId, {
|
|
602
|
+
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
603
|
+
toolNsMap,
|
|
604
|
+
freeformToolNames,
|
|
605
|
+
toolSearchToolNames,
|
|
606
|
+
...(routedCompaction ? { compaction: true } : {}),
|
|
607
|
+
});
|
|
608
|
+
// See the streaming branch: compaction turns skip the continuation cache.
|
|
609
|
+
if (!routedCompaction) rememberResponseState(parsed._rawBody, json, parsed._cursorConversationId);
|
|
610
|
+
return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return formatErrorResponse(500, "internal_error", "Non-streaming not supported by this adapter");
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export function linkAbortSignal(upstream: AbortController, signal?: AbortSignal): () => void {
|
|
617
|
+
if (!signal) return () => {};
|
|
618
|
+
if (signal.aborted) {
|
|
619
|
+
upstream.abort(signal.reason);
|
|
620
|
+
return () => {};
|
|
621
|
+
}
|
|
622
|
+
const onAbort = () => upstream.abort(signal.reason);
|
|
623
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
624
|
+
return () => signal.removeEventListener("abort", onAbort);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Remote compaction v1 (`POST /v1/responses/compact`). Codex uses this whenever the provider
|
|
629
|
+
* "is openai" and Feature::RemoteCompactionV2 is OFF (the default) — under Design B that is the
|
|
630
|
+
* proxy. The response is a unary `{"output":[ResponseItem...]}` that codex installs as the
|
|
631
|
+
* REPLACEMENT history (compact_remote.rs). Passthrough forwards to the real ChatGPT backend;
|
|
632
|
+
* routed models run the same summarizer used for v2 and convert the summary to v1 history items.
|
|
633
|
+
*/
|
|
634
|
+
export async function handleResponsesCompact(req: Request, config: OcxConfig): Promise<Response> {
|
|
635
|
+
let body: unknown;
|
|
636
|
+
try {
|
|
637
|
+
body = await readJsonRequestBody(req);
|
|
638
|
+
} catch (err) {
|
|
639
|
+
if (err instanceof UnsupportedContentEncodingError) {
|
|
640
|
+
return formatErrorResponse(415, "invalid_request_error", err.message);
|
|
641
|
+
}
|
|
642
|
+
return formatErrorResponse(400, "invalid_request_error", "Invalid JSON body");
|
|
643
|
+
}
|
|
644
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
645
|
+
return formatErrorResponse(400, "invalid_request_error", "Invalid compaction request body");
|
|
646
|
+
}
|
|
647
|
+
const raw = body as { model?: unknown; input?: unknown };
|
|
648
|
+
if (typeof raw.model !== "string" || raw.model.length === 0) {
|
|
649
|
+
return formatErrorResponse(400, "invalid_request_error", "compaction request requires a model");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
let route;
|
|
653
|
+
try {
|
|
654
|
+
route = routeModel(config, raw.model);
|
|
655
|
+
} catch (err) {
|
|
656
|
+
return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err));
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (route.provider.adapter === "openai-responses") {
|
|
660
|
+
// Native ChatGPT/OpenAI model: forward the compact request verbatim to the real backend.
|
|
661
|
+
// Resolve the SAME pool/thread auth context as /v1/responses — forwarding the caller's raw
|
|
662
|
+
// headers would run compaction on the wrong account (or 401) whenever a pool account is
|
|
663
|
+
// active for this thread while normal turns succeed.
|
|
664
|
+
let compactProvider = route.provider;
|
|
665
|
+
const headers = new Headers({ "content-type": "application/json" });
|
|
666
|
+
try {
|
|
667
|
+
const authCtx = await resolveCodexAuthContext(req.headers, config);
|
|
668
|
+
const selected = headersForCodexAuthContext(req.headers, authCtx);
|
|
669
|
+
compactProvider = applyCodexAuthContextToProvider(route.provider, authCtx);
|
|
670
|
+
for (const name of FORWARD_HEADERS) {
|
|
671
|
+
const value = selected.get(name);
|
|
672
|
+
if (value) headers.set(name, value);
|
|
673
|
+
}
|
|
674
|
+
const override = (compactProvider as { _codexAccountOverride?: { accessToken: string; chatgptAccountId: string } })._codexAccountOverride;
|
|
675
|
+
if (override) {
|
|
676
|
+
headers.set("authorization", `Bearer ${override.accessToken}`);
|
|
677
|
+
headers.set("chatgpt-account-id", override.chatgptAccountId);
|
|
678
|
+
}
|
|
679
|
+
} catch {
|
|
680
|
+
// Auth-context failures degrade to raw forwarded headers (pre-existing behavior) rather
|
|
681
|
+
// than failing the compact turn outright — codex-rs treats compact errors as session-fatal.
|
|
682
|
+
for (const name of FORWARD_HEADERS) {
|
|
683
|
+
const value = req.headers.get(name);
|
|
684
|
+
if (value) headers.set(name, value);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
const base = (compactProvider.baseUrl ?? "").replace(/\/$/, "");
|
|
688
|
+
if (compactProvider.apiKey) headers.set("authorization", `Bearer ${resolveEnvValue(compactProvider.apiKey)}`);
|
|
689
|
+
const upstream = await fetch(`${base}/responses/compact`, {
|
|
690
|
+
method: "POST",
|
|
691
|
+
headers,
|
|
692
|
+
body: JSON.stringify({ ...raw, model: route.modelId }),
|
|
693
|
+
});
|
|
694
|
+
return new Response(upstream.body, {
|
|
695
|
+
status: upstream.status,
|
|
696
|
+
headers: { "Content-Type": upstream.headers.get("content-type") ?? "application/json" },
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// ROUTED model: run the v2 synthetic-compaction turn internally (appends COMPACT_PROMPT, no
|
|
701
|
+
// tools) and decode the resulting ocx1 envelope into plain v1 replacement-history items.
|
|
702
|
+
const inputItems = Array.isArray(raw.input) ? (raw.input as unknown[]) : [];
|
|
703
|
+
const internalBody = {
|
|
704
|
+
...raw,
|
|
705
|
+
stream: false,
|
|
706
|
+
input: [...inputItems, { type: "compaction_trigger" }],
|
|
707
|
+
};
|
|
708
|
+
const internalHeaders = new Headers({ "content-type": "application/json" });
|
|
709
|
+
for (const name of FORWARD_HEADERS) {
|
|
710
|
+
const value = req.headers.get(name);
|
|
711
|
+
if (value) internalHeaders.set(name, value);
|
|
712
|
+
}
|
|
713
|
+
const internalReq = new Request("http://localhost/v1/responses", {
|
|
714
|
+
method: "POST",
|
|
715
|
+
headers: internalHeaders,
|
|
716
|
+
body: JSON.stringify(internalBody),
|
|
717
|
+
});
|
|
718
|
+
const logCtx: RequestLogContext = { model: route.modelId, provider: route.providerName };
|
|
719
|
+
const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal });
|
|
720
|
+
if (!response.ok) return response;
|
|
721
|
+
let json: { output?: unknown[] };
|
|
722
|
+
try {
|
|
723
|
+
json = await response.json() as { output?: unknown[] };
|
|
724
|
+
} catch {
|
|
725
|
+
return formatErrorResponse(502, "server_error", "compaction turn returned a non-JSON response");
|
|
726
|
+
}
|
|
727
|
+
const compactionItem = (json.output ?? []).find(
|
|
728
|
+
(item): item is { type: string; encrypted_content?: string } =>
|
|
729
|
+
!!item && typeof item === "object" && (item as { type?: string }).type === "compaction",
|
|
730
|
+
);
|
|
731
|
+
const summary = compactionItem?.encrypted_content
|
|
732
|
+
? decodeCompactionSummary(compactionItem.encrypted_content) ?? ""
|
|
733
|
+
: "";
|
|
734
|
+
const output = buildCompactV1Output(extractCompactUserMessages(inputItems), summary);
|
|
735
|
+
return new Response(JSON.stringify({ output }), { headers: { "Content-Type": "application/json" } });
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
export function disableResponsesRequestTimeout(req: Request, server: Pick<Server<WsData>, "timeout"> | undefined): boolean {
|
|
739
|
+
if (!server) return false;
|
|
740
|
+
try {
|
|
741
|
+
server.timeout(req, 0);
|
|
742
|
+
return true;
|
|
743
|
+
} catch {
|
|
744
|
+
return false;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Host-only label for retry logs — never leaks path/query/credentials. */
|
|
749
|
+
export function safeHostLabel(url: string): string {
|
|
750
|
+
try {
|
|
751
|
+
return new URL(url).host;
|
|
752
|
+
} catch {
|
|
753
|
+
return "upstream";
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
export async function fetchWithHeaderTimeout(
|
|
758
|
+
url: string,
|
|
759
|
+
init: Omit<RequestInit, "signal">,
|
|
760
|
+
abortSignal: AbortSignal,
|
|
761
|
+
timeoutMs: number,
|
|
762
|
+
): Promise<Response> {
|
|
763
|
+
const timeout = new AbortController();
|
|
764
|
+
const timer = setTimeout(() => {
|
|
765
|
+
if (!timeout.signal.aborted) timeout.abort(new DOMException("Timeout elapsed", "TimeoutError"));
|
|
766
|
+
}, timeoutMs);
|
|
767
|
+
try {
|
|
768
|
+
return await fetch(url, {
|
|
769
|
+
...init,
|
|
770
|
+
signal: AbortSignal.any([abortSignal, timeout.signal]),
|
|
771
|
+
});
|
|
772
|
+
} finally {
|
|
773
|
+
clearTimeout(timer);
|
|
774
|
+
}
|
|
775
|
+
}
|