@linxiraos/pi-agent-core 1.0.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/CHANGELOG.md +1263 -0
- package/README.md +473 -0
- package/dist/types/agent-loop.d.ts +137 -0
- package/dist/types/agent.d.ts +465 -0
- package/dist/types/append-only-context.d.ts +131 -0
- package/dist/types/compaction/branch-summarization.d.ts +101 -0
- package/dist/types/compaction/compaction-v2-streaming.d.ts +85 -0
- package/dist/types/compaction/compaction.d.ts +307 -0
- package/dist/types/compaction/entries.d.ts +111 -0
- package/dist/types/compaction/errors.d.ts +38 -0
- package/dist/types/compaction/index.d.ts +13 -0
- package/dist/types/compaction/message-cache.d.ts +23 -0
- package/dist/types/compaction/messages.d.ts +79 -0
- package/dist/types/compaction/openai.d.ts +117 -0
- package/dist/types/compaction/pruning.d.ts +105 -0
- package/dist/types/compaction/shake.d.ts +97 -0
- package/dist/types/compaction/tool-protection.d.ts +19 -0
- package/dist/types/compaction/utils.d.ts +61 -0
- package/dist/types/compaction.d.ts +1 -0
- package/dist/types/index.d.ts +13 -0
- package/dist/types/pause.d.ts +46 -0
- package/dist/types/proxy.d.ts +91 -0
- package/dist/types/replay-policy.d.ts +5 -0
- package/dist/types/run-collector.d.ts +196 -0
- package/dist/types/telemetry.d.ts +608 -0
- package/dist/types/thinking.d.ts +18 -0
- package/dist/types/tokenizer.d.ts +2 -0
- package/dist/types/types.d.ts +781 -0
- package/dist/types/utils/yield.d.ts +71 -0
- package/package.json +79 -0
- package/src/agent-loop.ts +2926 -0
- package/src/agent.ts +1748 -0
- package/src/append-only-context.ts +348 -0
- package/src/compaction/branch-summarization.ts +369 -0
- package/src/compaction/compaction-v2-streaming.ts +846 -0
- package/src/compaction/compaction.ts +1733 -0
- package/src/compaction/entries.ts +141 -0
- package/src/compaction/errors.ts +47 -0
- package/src/compaction/index.ts +14 -0
- package/src/compaction/message-cache.ts +92 -0
- package/src/compaction/messages.ts +241 -0
- package/src/compaction/openai.ts +992 -0
- package/src/compaction/prompts/auto-handoff-threshold-focus.md +1 -0
- package/src/compaction/prompts/branch-summary-context.md +5 -0
- package/src/compaction/prompts/branch-summary-preamble.md +2 -0
- package/src/compaction/prompts/branch-summary.md +30 -0
- package/src/compaction/prompts/compaction-short-summary.md +9 -0
- package/src/compaction/prompts/compaction-summary-context.md +5 -0
- package/src/compaction/prompts/compaction-summary.md +38 -0
- package/src/compaction/prompts/compaction-turn-prefix.md +17 -0
- package/src/compaction/prompts/compaction-update-summary.md +45 -0
- package/src/compaction/prompts/context-window-truncated-output.md +1 -0
- package/src/compaction/prompts/file-operations.md +5 -0
- package/src/compaction/prompts/handoff-document.md +49 -0
- package/src/compaction/prompts/snapcompact-archive-context.md +3 -0
- package/src/compaction/prompts/summarization-system.md +3 -0
- package/src/compaction/pruning.ts +427 -0
- package/src/compaction/shake.ts +446 -0
- package/src/compaction/tool-protection.ts +65 -0
- package/src/compaction/utils.ts +343 -0
- package/src/compaction.ts +1 -0
- package/src/index.ts +26 -0
- package/src/pause.ts +107 -0
- package/src/proxy.ts +391 -0
- package/src/replay-policy.ts +13 -0
- package/src/run-collector.ts +631 -0
- package/src/telemetry.ts +2078 -0
- package/src/thinking.ts +20 -0
- package/src/tokenizer.ts +27 -0
- package/src/types.ts +875 -0
- package/src/utils/yield.ts +183 -0
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote compaction utilities.
|
|
3
|
+
*
|
|
4
|
+
* Provider-side conversation summarization endpoints. Three flavors:
|
|
5
|
+
*
|
|
6
|
+
* - **OpenAI remote compaction V2** (Responses streaming): appends a
|
|
7
|
+
* `compaction_trigger` input item to the normal stream and stores the returned
|
|
8
|
+
* `compaction` item with retained real user messages in `preserveData`.
|
|
9
|
+
* - **OpenAI remote compaction V1** (`/responses/compact`): preserves encrypted
|
|
10
|
+
* reasoning across compactions by submitting the full responses-API native
|
|
11
|
+
* history and storing the returned `compaction` / `compaction_summary`
|
|
12
|
+
* item in `preserveData` so future turns can replay the encrypted state.
|
|
13
|
+
* - **Generic remote compaction**: a thin POST helper for self-hosted
|
|
14
|
+
* summarization endpoints that accept `{ systemPrompt, prompt }` and reply
|
|
15
|
+
* with `{ summary, shortSummary? }`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { ProviderHttpError } from "@linxiraos/pi-ai/error";
|
|
19
|
+
import { applyCodexResponsesLiteShape } from "@linxiraos/pi-ai/providers/openai-codex/request-transformer";
|
|
20
|
+
import {
|
|
21
|
+
createOpenAICodexCompactionRequestContext,
|
|
22
|
+
createOpenAICodexCompatibilityMetadata,
|
|
23
|
+
getCodexAttestationHeader,
|
|
24
|
+
} from "@linxiraos/pi-ai/providers/openai-codex-responses";
|
|
25
|
+
import { parseAzureDeploymentNameMap, parseTextSignature } from "@linxiraos/pi-ai/providers/openai-shared";
|
|
26
|
+
import { transformMessages } from "@linxiraos/pi-ai/providers/transform-messages";
|
|
27
|
+
import type {
|
|
28
|
+
Api,
|
|
29
|
+
AssistantMessage,
|
|
30
|
+
CodexCompactionContext,
|
|
31
|
+
FetchImpl,
|
|
32
|
+
Message,
|
|
33
|
+
Model,
|
|
34
|
+
ProviderSessionState,
|
|
35
|
+
} from "@linxiraos/pi-ai/types";
|
|
36
|
+
import {
|
|
37
|
+
getOpenAIResponsesHistoryItems,
|
|
38
|
+
getOpenAIResponsesHistoryPayload,
|
|
39
|
+
normalizeResponsesToolCallId,
|
|
40
|
+
stripOpenAIResponsesOutputOnlyStatusesForReplay,
|
|
41
|
+
} from "@linxiraos/pi-ai/utils";
|
|
42
|
+
import { captureOpenAIHttpError } from "@linxiraos/pi-ai/utils/openai-http";
|
|
43
|
+
import {
|
|
44
|
+
CODEX_BASE_URL,
|
|
45
|
+
getCodexAccountId,
|
|
46
|
+
OPENAI_HEADER_VALUES,
|
|
47
|
+
OPENAI_HEADERS,
|
|
48
|
+
} from "@linxiraos/pi-catalog/wire/codex";
|
|
49
|
+
import { $env, isRecord, logger, prompt, stringifyJson, structuredCloneJSON } from "@linxiraos/pi-utils";
|
|
50
|
+
import { countTokensConservatively } from "../tokenizer";
|
|
51
|
+
import contextWindowTruncatedOutputPrompt from "./prompts/context-window-truncated-output.md" with { type: "text" };
|
|
52
|
+
|
|
53
|
+
export * from "./compaction-v2-streaming";
|
|
54
|
+
|
|
55
|
+
// ============================================================================
|
|
56
|
+
// Public types
|
|
57
|
+
// ============================================================================
|
|
58
|
+
|
|
59
|
+
export const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Hard ceiling on remote compaction HTTP requests. Unlike every provider
|
|
63
|
+
* stream (guarded by first-event/idle watchdogs in pi-ai), these are raw
|
|
64
|
+
* fetches awaiting one non-streamed JSON body — a connection silently dropped
|
|
65
|
+
* by a middlebox would otherwise hang the whole compaction pipeline forever
|
|
66
|
+
* (frozen "Auto context-full maintenance…" spinner, manual /compact queueing
|
|
67
|
+
* behind it). On timeout the caller falls back to local summarization.
|
|
68
|
+
*/
|
|
69
|
+
export const REMOTE_COMPACTION_TIMEOUT_MS = 180_000;
|
|
70
|
+
|
|
71
|
+
const DEFAULT_AZURE_API_VERSION = "v1";
|
|
72
|
+
|
|
73
|
+
export const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE = prompt.render(contextWindowTruncatedOutputPrompt);
|
|
74
|
+
|
|
75
|
+
const REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS = 256;
|
|
76
|
+
const REMOTE_COMPACTION_IMAGE_TOKEN_ESTIMATE = 12_000;
|
|
77
|
+
const TOOL_RESULT_IMAGE_ATTACHMENT_TEXT = "Attached image(s) from tool result:";
|
|
78
|
+
|
|
79
|
+
interface NormalizedEstimateValue {
|
|
80
|
+
value: unknown;
|
|
81
|
+
imageTokens: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizeRemoteCompactionEstimateValue(value: unknown): NormalizedEstimateValue {
|
|
85
|
+
if (Array.isArray(value)) {
|
|
86
|
+
const normalized: unknown[] = [];
|
|
87
|
+
let imageTokens = 0;
|
|
88
|
+
for (const item of value) {
|
|
89
|
+
const result = normalizeRemoteCompactionEstimateValue(item);
|
|
90
|
+
normalized.push(result.value);
|
|
91
|
+
imageTokens += result.imageTokens;
|
|
92
|
+
}
|
|
93
|
+
return { value: normalized, imageTokens };
|
|
94
|
+
}
|
|
95
|
+
if (!value || typeof value !== "object") return { value, imageTokens: 0 };
|
|
96
|
+
|
|
97
|
+
const record = value as Record<string, unknown>;
|
|
98
|
+
if (record.type === "input_image") {
|
|
99
|
+
return {
|
|
100
|
+
value: { ...record, image_url: "<image>" },
|
|
101
|
+
imageTokens: REMOTE_COMPACTION_IMAGE_TOKEN_ESTIMATE,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const normalized: Record<string, unknown> = {};
|
|
106
|
+
let imageTokens = 0;
|
|
107
|
+
for (const [key, item] of Object.entries(record)) {
|
|
108
|
+
const result = normalizeRemoteCompactionEstimateValue(item);
|
|
109
|
+
normalized[key] = result.value;
|
|
110
|
+
imageTokens += result.imageTokens;
|
|
111
|
+
}
|
|
112
|
+
return { value: normalized, imageTokens };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface TrimRemoteCompactionInputResult {
|
|
116
|
+
input: Array<Record<string, unknown>>;
|
|
117
|
+
rewrittenOutputs: number;
|
|
118
|
+
estimatedTokensBefore: number;
|
|
119
|
+
estimatedTokensAfter: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function estimateRemoteCompactionInputTokens(
|
|
123
|
+
input: Array<Record<string, unknown>>,
|
|
124
|
+
instructions: string,
|
|
125
|
+
tools?: unknown[],
|
|
126
|
+
): number {
|
|
127
|
+
const normalized = normalizeRemoteCompactionEstimateValue({ instructions, input, ...(tools ? { tools } : {}) });
|
|
128
|
+
const serialized = stringifyJson(normalized.value) ?? "";
|
|
129
|
+
return countTokensConservatively(serialized) + normalized.imageTokens + REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function rewriteToolOutputForContextWindow(item: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
133
|
+
if (item.type === "function_call_output" || item.type === "custom_tool_call_output") {
|
|
134
|
+
return { ...item, output: CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE };
|
|
135
|
+
}
|
|
136
|
+
if (item.type === "tool_search_output") {
|
|
137
|
+
return { ...item, tools: [] };
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isToolResultImageAttachment(item: Record<string, unknown>): boolean {
|
|
143
|
+
if (item.type !== "message" || item.role !== "user" || !Array.isArray(item.content)) return false;
|
|
144
|
+
|
|
145
|
+
let hasLabel = false;
|
|
146
|
+
let hasImage = false;
|
|
147
|
+
for (const block of item.content) {
|
|
148
|
+
if (!isRecord(block)) continue;
|
|
149
|
+
if (block.type === "input_text" && block.text === TOOL_RESULT_IMAGE_ATTACHMENT_TEXT) hasLabel = true;
|
|
150
|
+
if (block.type === "input_image") hasImage = true;
|
|
151
|
+
}
|
|
152
|
+
return hasLabel && hasImage;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Preserve the full native transcript unless trailing tool outputs alone push a
|
|
157
|
+
* remote compaction request beyond the model window. Replacing only those
|
|
158
|
+
* outputs keeps call/result pairing and all earlier assistant/reasoning history,
|
|
159
|
+
* matching Codex's recovery path for oversized tool turns.
|
|
160
|
+
*/
|
|
161
|
+
export function trimRemoteCompactionInputToContextWindow(
|
|
162
|
+
input: Array<Record<string, unknown>>,
|
|
163
|
+
contextWindow: number | null | undefined,
|
|
164
|
+
instructions: string,
|
|
165
|
+
tools?: unknown[],
|
|
166
|
+
): TrimRemoteCompactionInputResult {
|
|
167
|
+
const estimatedTokensBefore = estimateRemoteCompactionInputTokens(input, instructions, tools);
|
|
168
|
+
if (!contextWindow || contextWindow <= 0 || estimatedTokensBefore <= contextWindow) {
|
|
169
|
+
return {
|
|
170
|
+
input,
|
|
171
|
+
rewrittenOutputs: 0,
|
|
172
|
+
estimatedTokensBefore,
|
|
173
|
+
estimatedTokensAfter: estimatedTokensBefore,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let rewrittenInput: Array<Record<string, unknown>> | undefined;
|
|
178
|
+
let estimatedTokensAfter = estimatedTokensBefore;
|
|
179
|
+
let rewrittenOutputs = 0;
|
|
180
|
+
for (let index = input.length - 1; index >= 0 && estimatedTokensAfter > contextWindow; index--) {
|
|
181
|
+
const item = input[index];
|
|
182
|
+
if (isToolResultImageAttachment(item)) continue;
|
|
183
|
+
const rewritten = rewriteToolOutputForContextWindow(item);
|
|
184
|
+
if (!rewritten) break;
|
|
185
|
+
rewrittenInput ??= input.slice();
|
|
186
|
+
rewrittenInput[index] = rewritten;
|
|
187
|
+
rewrittenOutputs++;
|
|
188
|
+
estimatedTokensAfter = estimateRemoteCompactionInputTokens(rewrittenInput, instructions, tools);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!rewrittenInput || estimatedTokensAfter > contextWindow) {
|
|
192
|
+
return {
|
|
193
|
+
input,
|
|
194
|
+
rewrittenOutputs: 0,
|
|
195
|
+
estimatedTokensBefore,
|
|
196
|
+
estimatedTokensAfter: estimatedTokensBefore,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
input: rewrittenInput,
|
|
202
|
+
rewrittenOutputs,
|
|
203
|
+
estimatedTokensBefore,
|
|
204
|
+
estimatedTokensAfter,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Race the caller's signal against the request timeout; `timeoutMs <= 0` disables the watchdog. */
|
|
209
|
+
function withRequestTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal | undefined {
|
|
210
|
+
if (timeoutMs <= 0) return signal;
|
|
211
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
212
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export type OpenAiRemoteCompactionItem = {
|
|
216
|
+
type: "compaction" | "compaction_summary";
|
|
217
|
+
encrypted_content?: string;
|
|
218
|
+
summary?: string;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
export interface OpenAiRemoteCompactionPreserveData {
|
|
222
|
+
provider?: string;
|
|
223
|
+
replacementHistory: Array<Record<string, unknown>>;
|
|
224
|
+
compactionItem: OpenAiRemoteCompactionItem;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface OpenAiRemoteCompactionRequest {
|
|
228
|
+
model: string;
|
|
229
|
+
input: Array<Record<string, unknown>>;
|
|
230
|
+
instructions: string;
|
|
231
|
+
reasoning?: {
|
|
232
|
+
context?: string;
|
|
233
|
+
[key: string]: unknown;
|
|
234
|
+
};
|
|
235
|
+
include?: string[];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface OpenAiRemoteCompactionResponse extends OpenAiRemoteCompactionPreserveData {}
|
|
239
|
+
|
|
240
|
+
export interface RemoteCompactionRequest {
|
|
241
|
+
systemPrompt: string;
|
|
242
|
+
prompt: string;
|
|
243
|
+
maxTokens?: number;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export interface RemoteCompactionResponse {
|
|
247
|
+
summary: string;
|
|
248
|
+
shortSummary?: string;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ============================================================================
|
|
252
|
+
// OpenAI provider gating + endpoint resolution
|
|
253
|
+
// ============================================================================
|
|
254
|
+
|
|
255
|
+
function isOpenAiRemoteCompactionApi(api: Api | undefined): boolean {
|
|
256
|
+
return api === "openai-responses" || api === "azure-openai-responses" || api === "openai-codex-responses";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function shouldUseOpenAiRemoteCompaction(model: Model): boolean {
|
|
260
|
+
if (model.remoteCompaction?.enabled === false) return false;
|
|
261
|
+
if (model.provider === "openai" || model.provider === "openai-codex") return true;
|
|
262
|
+
if (model.remoteCompaction?.enabled !== true) return false;
|
|
263
|
+
return isOpenAiRemoteCompactionApi(model.remoteCompaction.api ?? model.api);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function resolveOpenAiCompactEndpoint(model: Model): string {
|
|
267
|
+
const configuredEndpoint = model.remoteCompaction?.endpoint;
|
|
268
|
+
const compactionApi = model.remoteCompaction?.api ?? model.api;
|
|
269
|
+
if (compactionApi === "azure-openai-responses") {
|
|
270
|
+
return resolveAzureOpenAiCompactEndpoint(model, configuredEndpoint);
|
|
271
|
+
}
|
|
272
|
+
if (configuredEndpoint && configuredEndpoint.length > 0) return configuredEndpoint;
|
|
273
|
+
if (model.provider === "openai-codex" || compactionApi === "openai-codex-responses") {
|
|
274
|
+
return resolveOpenAiCodexCompactEndpoint(model.baseUrl);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const defaultBase = "https://api.openai.com/v1";
|
|
278
|
+
const rawBase = model.baseUrl && model.baseUrl.length > 0 ? model.baseUrl : defaultBase;
|
|
279
|
+
const normalizedBase = rawBase.endsWith("/") ? rawBase.slice(0, -1) : rawBase;
|
|
280
|
+
if (normalizedBase.endsWith("/v1")) return `${normalizedBase}/responses/compact`;
|
|
281
|
+
return `${normalizedBase}/v1/responses/compact`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function resolveAzureOpenAiCompactEndpoint(model: Model, configuredEndpoint: string | undefined): string {
|
|
285
|
+
const endpoint =
|
|
286
|
+
configuredEndpoint && configuredEndpoint.length > 0
|
|
287
|
+
? configuredEndpoint
|
|
288
|
+
: `${resolveAzureOpenAiBaseUrl(model)}/responses/compact`;
|
|
289
|
+
return appendAzureApiVersion(endpoint);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function resolveAzureOpenAiBaseUrl(model: Model): string {
|
|
293
|
+
const baseUrl = $env.AZURE_OPENAI_BASE_URL?.trim() || undefined;
|
|
294
|
+
const resourceName = $env.AZURE_OPENAI_RESOURCE_NAME;
|
|
295
|
+
const resolvedBaseUrl =
|
|
296
|
+
baseUrl ?? (resourceName ? `https://${resourceName}.openai.azure.com/openai/v1` : undefined) ?? model.baseUrl;
|
|
297
|
+
if (!resolvedBaseUrl) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
"Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or configure model.baseUrl.",
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
return resolvedBaseUrl.replace(/\/+$/, "");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function appendAzureApiVersion(endpoint: string): string {
|
|
306
|
+
if (/[?&]api-version=/.test(endpoint)) return endpoint;
|
|
307
|
+
const separator = endpoint.includes("?") ? "&" : "?";
|
|
308
|
+
return `${endpoint}${separator}api-version=${encodeURIComponent($env.AZURE_OPENAI_API_VERSION || DEFAULT_AZURE_API_VERSION)}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function resolveOpenAiCompactModel(model: Model): string {
|
|
312
|
+
const requestModel = model.remoteCompaction?.model ?? model.requestModelId ?? model.id;
|
|
313
|
+
const compactionApi = model.remoteCompaction?.api ?? model.api;
|
|
314
|
+
if (compactionApi !== "azure-openai-responses") return requestModel;
|
|
315
|
+
const mappedDeployment = parseAzureDeploymentNameMap($env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP).get(requestModel);
|
|
316
|
+
return mappedDeployment ?? requestModel;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function resolveOpenAiCodexCompactEndpoint(baseUrl: string | undefined): string {
|
|
320
|
+
const rawBase = baseUrl && baseUrl.length > 0 ? baseUrl : CODEX_BASE_URL;
|
|
321
|
+
const normalizedBase = rawBase.endsWith("/") ? rawBase.slice(0, -1) : rawBase;
|
|
322
|
+
if (/\/codex(?:\/v\d+)?$/.test(normalizedBase)) return `${normalizedBase}/responses/compact`;
|
|
323
|
+
return `${normalizedBase}/codex/responses/compact`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function normalizeOpenAiCompactionToolCallId(id: string): string {
|
|
327
|
+
const normalized = normalizeResponsesToolCallId(id);
|
|
328
|
+
return `${normalized.callId}|${normalized.itemId ?? normalized.callId}`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ============================================================================
|
|
332
|
+
// Preserve-data helpers
|
|
333
|
+
// ============================================================================
|
|
334
|
+
|
|
335
|
+
export function getPreservedOpenAiRemoteCompactionData(
|
|
336
|
+
preserveData: Record<string, unknown> | undefined,
|
|
337
|
+
): OpenAiRemoteCompactionPreserveData | undefined {
|
|
338
|
+
const candidate = preserveData?.[OPENAI_REMOTE_COMPACTION_PRESERVE_KEY];
|
|
339
|
+
if (!candidate || typeof candidate !== "object") return undefined;
|
|
340
|
+
const maybeData = candidate as { provider?: unknown; replacementHistory?: unknown; compactionItem?: unknown };
|
|
341
|
+
if (!Array.isArray(maybeData.replacementHistory)) return undefined;
|
|
342
|
+
const maybeItem = maybeData.compactionItem;
|
|
343
|
+
if (!maybeItem || typeof maybeItem !== "object") return undefined;
|
|
344
|
+
const compactionItem = maybeItem as { type?: unknown; encrypted_content?: unknown; summary?: unknown };
|
|
345
|
+
const isClassicCompaction =
|
|
346
|
+
compactionItem.type === "compaction" && typeof compactionItem.encrypted_content === "string";
|
|
347
|
+
const isSummaryCompaction = compactionItem.type === "compaction_summary";
|
|
348
|
+
if (!isClassicCompaction && !isSummaryCompaction) {
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
provider: typeof maybeData.provider === "string" ? maybeData.provider : undefined,
|
|
353
|
+
replacementHistory: maybeData.replacementHistory as Array<Record<string, unknown>>,
|
|
354
|
+
compactionItem: compactionItem as unknown as OpenAiRemoteCompactionItem,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function withOpenAiRemoteCompactionPreserveData(
|
|
359
|
+
preserveData: Record<string, unknown> | undefined,
|
|
360
|
+
remoteCompaction: OpenAiRemoteCompactionPreserveData | undefined,
|
|
361
|
+
): Record<string, unknown> | undefined {
|
|
362
|
+
if (remoteCompaction) {
|
|
363
|
+
return {
|
|
364
|
+
...(preserveData ?? {}),
|
|
365
|
+
[OPENAI_REMOTE_COMPACTION_PRESERVE_KEY]: remoteCompaction,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (!preserveData || !(OPENAI_REMOTE_COMPACTION_PRESERVE_KEY in preserveData)) {
|
|
370
|
+
return preserveData;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const { [OPENAI_REMOTE_COMPACTION_PRESERVE_KEY]: _removed, ...rest } = preserveData;
|
|
374
|
+
return Object.keys(rest).length > 0 ? rest : undefined;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ============================================================================
|
|
378
|
+
// Input/output filtering for OpenAI compact endpoint
|
|
379
|
+
// ============================================================================
|
|
380
|
+
|
|
381
|
+
function shouldKeepOpenAiCompactOutputItem(item: Record<string, unknown>): boolean {
|
|
382
|
+
if (item.type === "compaction" || item.type === "compaction_summary") return true;
|
|
383
|
+
if (item.type !== "message") return false;
|
|
384
|
+
return item.role === "assistant" || item.role === "user";
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Register every tool-call id in `items` (and the subset using the custom-tool
|
|
388
|
+
// wire shape) into the running sets. The history builder maintains both sets
|
|
389
|
+
// incrementally as native history is appended, so this only scans the
|
|
390
|
+
// newly-added items (or, after a full-snapshot replace, the fresh input) rather
|
|
391
|
+
// than re-scanning the whole growing history per message — the latter was
|
|
392
|
+
// O(N²) and blocked the event loop for seconds while compacting large codex
|
|
393
|
+
// contexts (frozen spinner until the next forced render).
|
|
394
|
+
function addOpenAiCallIds(
|
|
395
|
+
items: Array<Record<string, unknown>>,
|
|
396
|
+
knownCallIds: Set<string>,
|
|
397
|
+
customCallIds: Set<string>,
|
|
398
|
+
computerCallIds: Set<string>,
|
|
399
|
+
): void {
|
|
400
|
+
for (const item of items) {
|
|
401
|
+
if (typeof item.call_id !== "string") continue;
|
|
402
|
+
if (item.type === "function_call") {
|
|
403
|
+
knownCallIds.add(item.call_id);
|
|
404
|
+
} else if (item.type === "custom_tool_call") {
|
|
405
|
+
knownCallIds.add(item.call_id);
|
|
406
|
+
customCallIds.add(item.call_id);
|
|
407
|
+
} else if (item.type === "computer_call") {
|
|
408
|
+
knownCallIds.add(item.call_id);
|
|
409
|
+
computerCallIds.add(item.call_id);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function computerHistoryNote(item: Record<string, unknown>): Record<string, unknown> {
|
|
415
|
+
const serialized = stringifyJson(item) ?? "";
|
|
416
|
+
return {
|
|
417
|
+
type: "message",
|
|
418
|
+
id: `msg_${Bun.hash(`computer-history:${serialized}`).toString(36)}`,
|
|
419
|
+
role: "assistant",
|
|
420
|
+
content: [
|
|
421
|
+
{
|
|
422
|
+
type: "output_text",
|
|
423
|
+
text: `[Previous computer history unavailable to this model]: ${serialized}`,
|
|
424
|
+
annotations: [],
|
|
425
|
+
},
|
|
426
|
+
],
|
|
427
|
+
status: "completed",
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function adaptComputerHistoryForCompaction(
|
|
432
|
+
items: Array<Record<string, unknown>>,
|
|
433
|
+
supportsComputerUse: boolean,
|
|
434
|
+
): Array<Record<string, unknown>> {
|
|
435
|
+
if (supportsComputerUse) return items;
|
|
436
|
+
return items.map(item =>
|
|
437
|
+
item.type === "computer_call" || item.type === "computer_call_output" ? computerHistoryNote(item) : item,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function computerFailureNote(call: Record<string, unknown>, output: string): Record<string, unknown> {
|
|
442
|
+
const serialized = stringifyJson(call) ?? "";
|
|
443
|
+
return {
|
|
444
|
+
type: "message",
|
|
445
|
+
id: `msg_${Bun.hash(`computer-failure:${serialized}:${output}`).toString(36)}`,
|
|
446
|
+
role: "assistant",
|
|
447
|
+
content: [
|
|
448
|
+
{
|
|
449
|
+
type: "output_text",
|
|
450
|
+
text: `[Computer call failed before a screenshot was recorded]: ${serialized}${output ? `\n${output}` : ""}`,
|
|
451
|
+
annotations: [],
|
|
452
|
+
},
|
|
453
|
+
],
|
|
454
|
+
status: "completed",
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ============================================================================
|
|
459
|
+
// Native history construction (responses-API shape)
|
|
460
|
+
// ============================================================================
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Build the OpenAI Responses-API native history array from LLM messages.
|
|
464
|
+
*
|
|
465
|
+
* Caller is responsible for converting any custom message types to
|
|
466
|
+
* `Message[]` first (e.g. via the agent's `convertToLlm`); this function
|
|
467
|
+
* operates purely on the LLM-domain shape.
|
|
468
|
+
*
|
|
469
|
+
* @param messages - LLM messages to encode.
|
|
470
|
+
* @param model - Target model (used for provider gating + tool-call id rules).
|
|
471
|
+
* @param previousReplacementHistory - History from a prior compaction whose
|
|
472
|
+
* encrypted reasoning we want to preserve.
|
|
473
|
+
*/
|
|
474
|
+
export function buildOpenAiNativeHistory(
|
|
475
|
+
messages: Message[],
|
|
476
|
+
model: Model,
|
|
477
|
+
previousReplacementHistory?: Array<Record<string, unknown>>,
|
|
478
|
+
): Array<Record<string, unknown>> {
|
|
479
|
+
const input: Array<Record<string, unknown>> = previousReplacementHistory
|
|
480
|
+
? adaptComputerHistoryForCompaction([...previousReplacementHistory], model.supportsComputerUse === true)
|
|
481
|
+
: [];
|
|
482
|
+
const transformedMessages = transformMessages(messages, model, id => normalizeOpenAiCompactionToolCallId(id));
|
|
483
|
+
|
|
484
|
+
let msgIndex = 0;
|
|
485
|
+
const knownCallIds = new Set<string>();
|
|
486
|
+
const customCallIds = new Set<string>();
|
|
487
|
+
const computerCallIds = new Set<string>();
|
|
488
|
+
const demotedComputerCallIds = new Set<string>();
|
|
489
|
+
addOpenAiCallIds(input, knownCallIds, customCallIds, computerCallIds);
|
|
490
|
+
for (const message of transformedMessages) {
|
|
491
|
+
if (message.role === "user" || message.role === "developer") {
|
|
492
|
+
const providerPayload = (message as { providerPayload?: AssistantMessage["providerPayload"] }).providerPayload;
|
|
493
|
+
const rawHistoryItems = getOpenAIResponsesHistoryItems(providerPayload, model.provider);
|
|
494
|
+
if (rawHistoryItems) {
|
|
495
|
+
if (model.supportsComputerUse !== true) {
|
|
496
|
+
for (const item of rawHistoryItems) {
|
|
497
|
+
if (item.type === "computer_call" && typeof item.call_id === "string") {
|
|
498
|
+
demotedComputerCallIds.add(item.call_id);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const historyItems = adaptComputerHistoryForCompaction(rawHistoryItems, model.supportsComputerUse === true);
|
|
503
|
+
input.push(...historyItems);
|
|
504
|
+
addOpenAiCallIds(historyItems, knownCallIds, customCallIds, computerCallIds);
|
|
505
|
+
msgIndex++;
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const contentBlocks: Array<Record<string, unknown>> = [];
|
|
510
|
+
if (typeof message.content === "string") {
|
|
511
|
+
if (message.content.trim().length > 0) {
|
|
512
|
+
contentBlocks.push({ type: "input_text", text: message.content.toWellFormed() });
|
|
513
|
+
}
|
|
514
|
+
} else {
|
|
515
|
+
for (const block of message.content) {
|
|
516
|
+
if (block.type === "text") {
|
|
517
|
+
if (!block.text || block.text.trim().length === 0) continue;
|
|
518
|
+
contentBlocks.push({ type: "input_text", text: block.text.toWellFormed() });
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (block.type === "image") {
|
|
522
|
+
contentBlocks.push({
|
|
523
|
+
type: "input_image",
|
|
524
|
+
detail: "auto",
|
|
525
|
+
image_url: `data:${block.mimeType};base64,${block.data}`,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (contentBlocks.length > 0) {
|
|
531
|
+
input.push({ type: "message", role: message.role, content: contentBlocks });
|
|
532
|
+
}
|
|
533
|
+
msgIndex++;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (message.role === "assistant") {
|
|
538
|
+
const assistant = message as AssistantMessage;
|
|
539
|
+
const providerPayload = getOpenAIResponsesHistoryPayload(
|
|
540
|
+
assistant.providerPayload,
|
|
541
|
+
model.provider,
|
|
542
|
+
assistant.provider,
|
|
543
|
+
);
|
|
544
|
+
if (providerPayload) {
|
|
545
|
+
if (!providerPayload.dt) demotedComputerCallIds.clear();
|
|
546
|
+
if (model.supportsComputerUse !== true) {
|
|
547
|
+
for (const item of providerPayload.items) {
|
|
548
|
+
if (item.type === "computer_call" && typeof item.call_id === "string") {
|
|
549
|
+
demotedComputerCallIds.add(item.call_id);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const historyItems = adaptComputerHistoryForCompaction(
|
|
554
|
+
providerPayload.items,
|
|
555
|
+
model.supportsComputerUse === true,
|
|
556
|
+
);
|
|
557
|
+
if (providerPayload.dt) {
|
|
558
|
+
input.push(...historyItems);
|
|
559
|
+
addOpenAiCallIds(historyItems, knownCallIds, customCallIds, computerCallIds);
|
|
560
|
+
} else {
|
|
561
|
+
input.splice(0, input.length, ...historyItems);
|
|
562
|
+
knownCallIds.clear();
|
|
563
|
+
customCallIds.clear();
|
|
564
|
+
computerCallIds.clear();
|
|
565
|
+
addOpenAiCallIds(input, knownCallIds, customCallIds, computerCallIds);
|
|
566
|
+
}
|
|
567
|
+
msgIndex++;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
const isDifferentModel =
|
|
571
|
+
assistant.model !== model.id && assistant.provider === model.provider && assistant.api === model.api;
|
|
572
|
+
|
|
573
|
+
for (const block of assistant.content) {
|
|
574
|
+
if (block.type === "thinking" && assistant.stopReason !== "error" && block.thinkingSignature) {
|
|
575
|
+
try {
|
|
576
|
+
const reasoningItem = JSON.parse(block.thinkingSignature) as Record<string, unknown>;
|
|
577
|
+
if (reasoningItem && typeof reasoningItem === "object") {
|
|
578
|
+
input.push(reasoningItem);
|
|
579
|
+
}
|
|
580
|
+
} catch {
|
|
581
|
+
logger.warn("Failed to parse assistant reasoning for remote compaction", {
|
|
582
|
+
model: assistant.model,
|
|
583
|
+
provider: assistant.provider,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (block.type === "text") {
|
|
590
|
+
if (!block.text || block.text.trim().length === 0) continue;
|
|
591
|
+
const parsedSignature = parseTextSignature(block.textSignature);
|
|
592
|
+
let msgId = parsedSignature?.id;
|
|
593
|
+
if (!msgId) {
|
|
594
|
+
msgId = `msg_${msgIndex}`;
|
|
595
|
+
} else if (msgId.length > 64) {
|
|
596
|
+
msgId = `msg_${Bun.hash(msgId).toString(36)}`;
|
|
597
|
+
}
|
|
598
|
+
input.push({
|
|
599
|
+
type: "message",
|
|
600
|
+
role: "assistant",
|
|
601
|
+
content: [{ type: "output_text", text: block.text.toWellFormed(), annotations: [] }],
|
|
602
|
+
status: "completed",
|
|
603
|
+
id: msgId,
|
|
604
|
+
phase: parsedSignature?.phase,
|
|
605
|
+
});
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (block.type === "toolCall") {
|
|
610
|
+
const normalized = normalizeResponsesToolCallId(block.id, block.customWireName ? "ctc" : "fc");
|
|
611
|
+
if (block.providerMetadata?.type === "computer") {
|
|
612
|
+
const computerCall = {
|
|
613
|
+
type: "computer_call",
|
|
614
|
+
id: block.providerMetadata.providerItemId,
|
|
615
|
+
call_id: normalized.callId,
|
|
616
|
+
actions: structuredCloneJSON(block.providerMetadata.actions),
|
|
617
|
+
pending_safety_checks: structuredCloneJSON(block.providerMetadata.pendingSafetyChecks),
|
|
618
|
+
status: "completed",
|
|
619
|
+
};
|
|
620
|
+
if (model.supportsComputerUse !== true) {
|
|
621
|
+
input.push(computerHistoryNote(computerCall));
|
|
622
|
+
demotedComputerCallIds.add(normalized.callId);
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
knownCallIds.add(normalized.callId);
|
|
626
|
+
computerCallIds.add(normalized.callId);
|
|
627
|
+
input.push(computerCall);
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
let itemId: string | undefined = normalized.itemId;
|
|
631
|
+
if (
|
|
632
|
+
isDifferentModel &&
|
|
633
|
+
(itemId?.startsWith("fc_") || itemId?.startsWith("fcr_") || itemId?.startsWith("ctc_"))
|
|
634
|
+
) {
|
|
635
|
+
itemId = undefined;
|
|
636
|
+
}
|
|
637
|
+
knownCallIds.add(normalized.callId);
|
|
638
|
+
if (block.customWireName) {
|
|
639
|
+
const rawInput = typeof block.arguments?.input === "string" ? block.arguments.input : "";
|
|
640
|
+
customCallIds.add(normalized.callId);
|
|
641
|
+
input.push({
|
|
642
|
+
type: "custom_tool_call",
|
|
643
|
+
id: itemId,
|
|
644
|
+
call_id: normalized.callId,
|
|
645
|
+
name: block.customWireName,
|
|
646
|
+
input: rawInput,
|
|
647
|
+
});
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
input.push({
|
|
651
|
+
type: "function_call",
|
|
652
|
+
id: itemId,
|
|
653
|
+
call_id: normalized.callId,
|
|
654
|
+
name: block.name,
|
|
655
|
+
arguments: stringifyJson(block.arguments) ?? "null",
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
msgIndex++;
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (message.role === "toolResult") {
|
|
665
|
+
const normalized = normalizeResponsesToolCallId(message.toolCallId);
|
|
666
|
+
const textOutput = message.content
|
|
667
|
+
.filter(block => block.type === "text")
|
|
668
|
+
.map(block => block.text)
|
|
669
|
+
.join("\n");
|
|
670
|
+
const hasImages = message.content.some(block => block.type === "image");
|
|
671
|
+
const outputText = textOutput.length > 0 ? textOutput : hasImages ? "(see attached image)" : "";
|
|
672
|
+
if (demotedComputerCallIds.has(normalized.callId)) {
|
|
673
|
+
const resultItem =
|
|
674
|
+
message.providerMetadata?.type === "computer"
|
|
675
|
+
? {
|
|
676
|
+
type: "computer_call_output",
|
|
677
|
+
call_id: normalized.callId,
|
|
678
|
+
output: structuredCloneJSON(message.providerMetadata.screenshot),
|
|
679
|
+
acknowledged_safety_checks: structuredCloneJSON(
|
|
680
|
+
message.providerMetadata.acknowledgedSafetyChecks,
|
|
681
|
+
),
|
|
682
|
+
}
|
|
683
|
+
: { type: "computer_call_output", call_id: normalized.callId, error: outputText };
|
|
684
|
+
input.push(computerHistoryNote(resultItem));
|
|
685
|
+
demotedComputerCallIds.delete(normalized.callId);
|
|
686
|
+
msgIndex++;
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
if (!knownCallIds.has(normalized.callId)) {
|
|
690
|
+
msgIndex++;
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
if (computerCallIds.has(normalized.callId)) {
|
|
694
|
+
if (message.providerMetadata?.type === "computer") {
|
|
695
|
+
input.push({
|
|
696
|
+
type: "computer_call_output",
|
|
697
|
+
call_id: normalized.callId,
|
|
698
|
+
output: structuredCloneJSON(message.providerMetadata.screenshot),
|
|
699
|
+
acknowledged_safety_checks: structuredCloneJSON(message.providerMetadata.acknowledgedSafetyChecks),
|
|
700
|
+
});
|
|
701
|
+
msgIndex++;
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const callIndex = input.findLastIndex(
|
|
706
|
+
item => item.type === "computer_call" && item.call_id === normalized.callId,
|
|
707
|
+
);
|
|
708
|
+
if (callIndex >= 0) {
|
|
709
|
+
const [call] = input.splice(callIndex, 1);
|
|
710
|
+
if (call) input.splice(callIndex, 0, computerFailureNote(call, outputText));
|
|
711
|
+
}
|
|
712
|
+
knownCallIds.delete(normalized.callId);
|
|
713
|
+
computerCallIds.delete(normalized.callId);
|
|
714
|
+
msgIndex++;
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
input.push({
|
|
719
|
+
type: customCallIds.has(normalized.callId) ? "custom_tool_call_output" : "function_call_output",
|
|
720
|
+
call_id: normalized.callId,
|
|
721
|
+
output: outputText.toWellFormed(),
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
if (hasImages && model.input.includes("image")) {
|
|
725
|
+
const contentBlocks: Array<Record<string, unknown>> = [
|
|
726
|
+
{ type: "input_text", text: TOOL_RESULT_IMAGE_ATTACHMENT_TEXT },
|
|
727
|
+
];
|
|
728
|
+
for (const block of message.content) {
|
|
729
|
+
if (block.type !== "image") continue;
|
|
730
|
+
contentBlocks.push({
|
|
731
|
+
type: "input_image",
|
|
732
|
+
detail: "auto",
|
|
733
|
+
image_url: `data:${block.mimeType};base64,${block.data}`,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
input.push({ type: "message", role: "user", content: contentBlocks });
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
msgIndex++;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return stripOpenAIResponsesOutputOnlyStatusesForReplay(input);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ============================================================================
|
|
747
|
+
// Endpoint requests
|
|
748
|
+
// ============================================================================
|
|
749
|
+
export async function requestOpenAiRemoteCompaction(
|
|
750
|
+
model: Model,
|
|
751
|
+
apiKey: string,
|
|
752
|
+
compactInput: Array<Record<string, unknown>>,
|
|
753
|
+
instructions: string,
|
|
754
|
+
signal?: AbortSignal,
|
|
755
|
+
opts?: {
|
|
756
|
+
fetch?: FetchImpl;
|
|
757
|
+
timeoutMs?: number;
|
|
758
|
+
sessionId?: string;
|
|
759
|
+
providerSessionState?: Map<string, ProviderSessionState>;
|
|
760
|
+
codexCompaction?: CodexCompactionContext;
|
|
761
|
+
},
|
|
762
|
+
): Promise<OpenAiRemoteCompactionResponse> {
|
|
763
|
+
const endpoint = resolveOpenAiCompactEndpoint(model);
|
|
764
|
+
const requestModel = resolveOpenAiCompactModel(model);
|
|
765
|
+
const trimmed = trimRemoteCompactionInputToContextWindow(compactInput, model.contextWindow, instructions);
|
|
766
|
+
if (trimmed.rewrittenOutputs > 0) {
|
|
767
|
+
logger.info("Rewrote trailing tool outputs before OpenAI remote compaction", {
|
|
768
|
+
model: model.id,
|
|
769
|
+
provider: model.provider,
|
|
770
|
+
rewrittenOutputs: trimmed.rewrittenOutputs,
|
|
771
|
+
estimatedTokensBefore: trimmed.estimatedTokensBefore,
|
|
772
|
+
estimatedTokensAfter: trimmed.estimatedTokensAfter,
|
|
773
|
+
contextWindow: model.contextWindow,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
const request: OpenAiRemoteCompactionRequest = {
|
|
777
|
+
model: requestModel,
|
|
778
|
+
// Preserve the native transcript. Only oversized trailing tool outputs are
|
|
779
|
+
// rewritten above, reducing the request without losing assistant turns,
|
|
780
|
+
// reasoning, or call/result pairing.
|
|
781
|
+
input: trimmed.input,
|
|
782
|
+
instructions,
|
|
783
|
+
};
|
|
784
|
+
const isAzureOpenAiResponses = (model.remoteCompaction?.api ?? model.api) === "azure-openai-responses";
|
|
785
|
+
const isCodexResponses =
|
|
786
|
+
model.provider === "openai-codex" || (model.remoteCompaction?.api ?? model.api) === "openai-codex-responses";
|
|
787
|
+
const headers: Record<string, string> = isAzureOpenAiResponses
|
|
788
|
+
? {
|
|
789
|
+
"content-type": "application/json",
|
|
790
|
+
"api-key": apiKey,
|
|
791
|
+
...(model.headers ?? {}),
|
|
792
|
+
}
|
|
793
|
+
: {
|
|
794
|
+
"content-type": "application/json",
|
|
795
|
+
Authorization: `Bearer ${apiKey}`,
|
|
796
|
+
...(model.headers ?? {}),
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
// Codex endpoints require additional auth headers
|
|
800
|
+
if (isCodexResponses) {
|
|
801
|
+
const accountId = getCodexAccountId(apiKey);
|
|
802
|
+
if (accountId) {
|
|
803
|
+
headers[OPENAI_HEADERS.ACCOUNT_ID] = accountId;
|
|
804
|
+
}
|
|
805
|
+
const attestation = await getCodexAttestationHeader(accountId);
|
|
806
|
+
if (attestation) {
|
|
807
|
+
headers[OPENAI_HEADERS.ATTESTATION] = attestation;
|
|
808
|
+
}
|
|
809
|
+
headers[OPENAI_HEADERS.BETA] = OPENAI_HEADER_VALUES.BETA_RESPONSES;
|
|
810
|
+
headers[OPENAI_HEADERS.ORIGINATOR] = OPENAI_HEADER_VALUES.ORIGINATOR_CODEX;
|
|
811
|
+
Object.assign(
|
|
812
|
+
headers,
|
|
813
|
+
createOpenAICodexCompatibilityMetadata({
|
|
814
|
+
sessionId: opts?.sessionId,
|
|
815
|
+
providerSessionState: opts?.providerSessionState,
|
|
816
|
+
requestKind: "compaction",
|
|
817
|
+
compaction: createOpenAICodexCompactionRequestContext({
|
|
818
|
+
context: opts?.codexCompaction,
|
|
819
|
+
implementation: "responses_compact",
|
|
820
|
+
}),
|
|
821
|
+
includeInstallationHeader: true,
|
|
822
|
+
}).headers,
|
|
823
|
+
);
|
|
824
|
+
// Responses Lite models take the same rewrite on `/responses/compact`:
|
|
825
|
+
// instructions ride as an input item and the lite marker header is set
|
|
826
|
+
// (codex-rs routes compaction through `build_responses_request`).
|
|
827
|
+
if (model.useResponsesLite) {
|
|
828
|
+
applyCodexResponsesLiteShape(request);
|
|
829
|
+
headers[OPENAI_HEADERS.RESPONSES_LITE] = "true";
|
|
830
|
+
request.reasoning = {
|
|
831
|
+
...request.reasoning,
|
|
832
|
+
context: "all_turns",
|
|
833
|
+
};
|
|
834
|
+
request.include = Array.from(new Set([...(request.include ?? []), "reasoning.encrypted_content"]));
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const response = await (opts?.fetch ?? fetch)(endpoint, {
|
|
839
|
+
method: "POST",
|
|
840
|
+
headers,
|
|
841
|
+
body: stringifyJson(request),
|
|
842
|
+
signal: withRequestTimeout(signal, opts?.timeoutMs ?? REMOTE_COMPACTION_TIMEOUT_MS),
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
if (!response.ok) {
|
|
846
|
+
const cause = await captureOpenAIHttpError(response);
|
|
847
|
+
logger.warn("OpenAI remote compaction failed", {
|
|
848
|
+
endpoint,
|
|
849
|
+
status: response.status,
|
|
850
|
+
statusText: response.statusText,
|
|
851
|
+
errorText: cause.captured.bodyText ?? "",
|
|
852
|
+
});
|
|
853
|
+
throw new ProviderHttpError(
|
|
854
|
+
`Remote compaction failed (${response.status} ${response.statusText})`,
|
|
855
|
+
response.status,
|
|
856
|
+
{
|
|
857
|
+
headers: response.headers,
|
|
858
|
+
cause,
|
|
859
|
+
},
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const data = (await response.json()) as { output?: unknown[] } | undefined;
|
|
864
|
+
const rawOutput = data?.output ?? [];
|
|
865
|
+
const replacementHistory = rawOutput.filter(
|
|
866
|
+
(item): item is Record<string, unknown> =>
|
|
867
|
+
!!item && typeof item === "object" && shouldKeepOpenAiCompactOutputItem(item as Record<string, unknown>),
|
|
868
|
+
);
|
|
869
|
+
const compactionItem = replacementHistory.findLast((item): item is OpenAiRemoteCompactionItem => {
|
|
870
|
+
if (item.type === "compaction" && typeof item.encrypted_content === "string") return true;
|
|
871
|
+
if (item.type === "compaction_summary") return true;
|
|
872
|
+
return false;
|
|
873
|
+
});
|
|
874
|
+
if (!compactionItem) {
|
|
875
|
+
const outputTypes = rawOutput.map(item =>
|
|
876
|
+
typeof item === "object" && item !== null ? (item as Record<string, unknown>).type : typeof item,
|
|
877
|
+
);
|
|
878
|
+
logger.warn("Remote compaction response missing compaction item", {
|
|
879
|
+
endpoint,
|
|
880
|
+
model: model.id,
|
|
881
|
+
provider: model.provider,
|
|
882
|
+
rawOutputLength: rawOutput.length,
|
|
883
|
+
outputTypes,
|
|
884
|
+
replacementHistoryLength: replacementHistory.length,
|
|
885
|
+
});
|
|
886
|
+
throw new Error("Remote compaction response missing compaction item");
|
|
887
|
+
}
|
|
888
|
+
return { provider: model.provider, replacementHistory, compactionItem };
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Generic remote-compaction POST. Two wire shapes are auto-selected by
|
|
893
|
+
* endpoint suffix so a single `compaction.remoteEndpoint` setting can point at
|
|
894
|
+
* either a purpose-built omp summarizer (`{systemPrompt, prompt}` → `{summary}`)
|
|
895
|
+
* or any OpenAI-compatible chat-completions server (`/chat/completions`,
|
|
896
|
+
* `/v1/chat/completions`, …) as reported for llama.cpp / vLLM / etc. in
|
|
897
|
+
* issue #4630: without this, the omp payload was rejected with
|
|
898
|
+
* HTTP 400 `"'messages' is required"`, compaction silently fell back to
|
|
899
|
+
* local summarization, and context grew unbounded.
|
|
900
|
+
*
|
|
901
|
+
* When `context.model` is provided the chat-completions body is tagged with
|
|
902
|
+
* that model's wire id (llama.cpp requires the field) and `context.apiKey` is
|
|
903
|
+
* forwarded as `Authorization: Bearer`. Callers wrap this in `withAuth` so
|
|
904
|
+
* 401s force-refresh through the standard credential rotation policy.
|
|
905
|
+
*/
|
|
906
|
+
export async function requestRemoteCompaction(
|
|
907
|
+
endpoint: string,
|
|
908
|
+
request: RemoteCompactionRequest,
|
|
909
|
+
signal?: AbortSignal,
|
|
910
|
+
opts?: { fetch?: FetchImpl; timeoutMs?: number; model?: Model; apiKey?: string },
|
|
911
|
+
): Promise<RemoteCompactionResponse> {
|
|
912
|
+
let endpointPath = endpoint;
|
|
913
|
+
try {
|
|
914
|
+
endpointPath = new URL(endpoint).pathname;
|
|
915
|
+
} catch {
|
|
916
|
+
// Keep the raw endpoint for relative/custom fetch implementations.
|
|
917
|
+
}
|
|
918
|
+
const isChatCompletions = /\/chat\/completions\/?$/.test(endpointPath);
|
|
919
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
920
|
+
if (isChatCompletions) {
|
|
921
|
+
if (opts?.apiKey) headers.Authorization = `Bearer ${opts.apiKey}`;
|
|
922
|
+
if (opts?.model?.headers) Object.assign(headers, opts.model.headers);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const body: Record<string, unknown> = isChatCompletions
|
|
926
|
+
? {
|
|
927
|
+
model: opts?.model ? resolveOpenAiCompactModel(opts.model) : undefined,
|
|
928
|
+
messages: [
|
|
929
|
+
{ role: "system", content: request.systemPrompt },
|
|
930
|
+
{ role: "user", content: request.prompt },
|
|
931
|
+
],
|
|
932
|
+
stream: false,
|
|
933
|
+
max_tokens: request.maxTokens,
|
|
934
|
+
}
|
|
935
|
+
: { systemPrompt: request.systemPrompt, prompt: request.prompt, maxTokens: request.maxTokens };
|
|
936
|
+
|
|
937
|
+
const response = await (opts?.fetch ?? fetch)(endpoint, {
|
|
938
|
+
method: "POST",
|
|
939
|
+
headers,
|
|
940
|
+
body: stringifyJson(body),
|
|
941
|
+
signal: withRequestTimeout(signal, opts?.timeoutMs ?? REMOTE_COMPACTION_TIMEOUT_MS),
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
if (!response.ok) {
|
|
945
|
+
const errorText = await response.text().catch(() => "");
|
|
946
|
+
logger.warn("Remote compaction failed", {
|
|
947
|
+
endpoint,
|
|
948
|
+
status: response.status,
|
|
949
|
+
statusText: response.statusText,
|
|
950
|
+
errorText,
|
|
951
|
+
});
|
|
952
|
+
throw new ProviderHttpError(
|
|
953
|
+
`Remote compaction failed (${response.status} ${response.statusText})`,
|
|
954
|
+
response.status,
|
|
955
|
+
{
|
|
956
|
+
headers: response.headers,
|
|
957
|
+
},
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
if (isChatCompletions) {
|
|
962
|
+
type ChatCompletionsResponse = {
|
|
963
|
+
choices?: Array<{
|
|
964
|
+
message?: {
|
|
965
|
+
content?: string | Array<{ type?: string; text?: string }> | null;
|
|
966
|
+
};
|
|
967
|
+
}>;
|
|
968
|
+
};
|
|
969
|
+
const data = (await response.json()) as ChatCompletionsResponse | undefined;
|
|
970
|
+
const choice = data?.choices?.[0]?.message?.content;
|
|
971
|
+
let summary: string | undefined;
|
|
972
|
+
if (typeof choice === "string") {
|
|
973
|
+
summary = choice;
|
|
974
|
+
} else if (Array.isArray(choice)) {
|
|
975
|
+
summary = choice
|
|
976
|
+
.filter((part): part is { type?: string; text: string } => typeof part?.text === "string")
|
|
977
|
+
.map(part => part.text)
|
|
978
|
+
.join("");
|
|
979
|
+
}
|
|
980
|
+
if (typeof summary !== "string" || summary.length === 0) {
|
|
981
|
+
throw new Error("Remote compaction response missing choices[0].message.content");
|
|
982
|
+
}
|
|
983
|
+
return { summary };
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const data = (await response.json()) as RemoteCompactionResponse | undefined;
|
|
987
|
+
if (!data || typeof data.summary !== "string") {
|
|
988
|
+
throw new Error("Remote compaction response missing summary");
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
return data;
|
|
992
|
+
}
|