codex-copilot-dx 0.4.42 → 0.4.44

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.
@@ -0,0 +1,113 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { httpError } from "./http-transport.mjs";
3
+
4
+ const COMPACTION_TRIGGER_TYPE = "compaction_trigger";
5
+ const RETAINED_MESSAGE_ROLES = new Set(["user", "assistant", "developer", "system"]);
6
+ const RETAINED_MESSAGE_TOKEN_BUDGET = 64_000;
7
+ const APPROX_BYTES_PER_TOKEN = 4;
8
+
9
+ function compactionError(message) {
10
+ const error = httpError(message, 502);
11
+ error.code = "ccdx_invalid_compaction_response";
12
+ error.jsonBody = {
13
+ error: {
14
+ message,
15
+ type: "upstream_error",
16
+ code: error.code,
17
+ },
18
+ };
19
+ return error;
20
+ }
21
+
22
+ function normalizeMessageContent(content) {
23
+ if (typeof content === "string") return [{ type: "input_text", text: content }];
24
+ if (!Array.isArray(content)) return [];
25
+ return content.map((part) => {
26
+ if (!part || typeof part !== "object") return part;
27
+ return part.type === "output_text"
28
+ ? { ...part, type: "input_text" }
29
+ : structuredClone(part);
30
+ });
31
+ }
32
+
33
+ function approximateTextTokens(content) {
34
+ return content.reduce((total, part) => {
35
+ if (part?.type !== "input_text" || typeof part.text !== "string") return total;
36
+ return total + Math.ceil(Buffer.byteLength(part.text) / APPROX_BYTES_PER_TOKEN);
37
+ }, 0);
38
+ }
39
+
40
+ function retainedMessages(inputItems) {
41
+ const kept = [];
42
+ let usedTokens = 0;
43
+ for (let index = inputItems.length - 1; index >= 0; index -= 1) {
44
+ const item = inputItems[index];
45
+ if (item?.type !== "message" || !RETAINED_MESSAGE_ROLES.has(item.role)) continue;
46
+
47
+ const content = normalizeMessageContent(item.content);
48
+ usedTokens += Math.max(approximateTextTokens(content), 1);
49
+ if (usedTokens > RETAINED_MESSAGE_TOKEN_BUDGET && kept.length > 0) break;
50
+
51
+ kept.push({
52
+ type: "message",
53
+ id: `msg_${randomUUID().replaceAll("-", "")}`,
54
+ status: item.status ?? "completed",
55
+ role: item.role,
56
+ content,
57
+ ...(item.phase !== undefined ? { phase: structuredClone(item.phase) } : {}),
58
+ });
59
+ }
60
+ return kept.reverse();
61
+ }
62
+
63
+ export function prepareResponsesCompactionRequest(reqContext) {
64
+ const input = Array.isArray(reqContext?.body?.input) ? reqContext.body.input : [];
65
+ const compactionInputItems = compactionInputWithoutTrigger(input);
66
+ reqContext.body.input = [...compactionInputItems, { type: COMPACTION_TRIGGER_TYPE }];
67
+ reqContext.body.stream = false;
68
+ return reqContext;
69
+ }
70
+
71
+ export function compactionInputWithoutTrigger(input) {
72
+ return Array.isArray(input)
73
+ ? input.filter((item) => item?.type !== COMPACTION_TRIGGER_TYPE)
74
+ : [];
75
+ }
76
+
77
+ export function createResponsesCompactionResult(inputItems, generated) {
78
+ if (!generated || typeof generated !== "object" || !generated.id || !Array.isArray(generated.output)) {
79
+ throw compactionError("Copilot compaction returned an invalid response envelope");
80
+ }
81
+ if (Object.prototype.hasOwnProperty.call(generated, "status") && generated.status !== "completed") {
82
+ throw compactionError(`Copilot compaction returned status ${generated.status}`);
83
+ }
84
+
85
+ const compactionItems = generated.output.filter((item) => item?.type === "compaction");
86
+ if (!compactionItems.length) {
87
+ throw compactionError("Copilot compaction returned no compaction output item");
88
+ }
89
+ if (compactionItems.some((item) => (
90
+ typeof item.encrypted_content !== "string" || item.encrypted_content.length === 0
91
+ ))) {
92
+ throw compactionError("Copilot compaction returned an invalid compaction output item");
93
+ }
94
+
95
+ return {
96
+ ...generated,
97
+ object: "response.compaction",
98
+ output: [
99
+ ...retainedMessages(Array.isArray(inputItems) ? inputItems : []),
100
+ ...compactionItems,
101
+ ],
102
+ };
103
+ }
104
+
105
+ export function parseResponsesCompactionResult(inputItems, text) {
106
+ let generated;
107
+ try {
108
+ generated = JSON.parse(text);
109
+ } catch {
110
+ throw compactionError("Copilot compaction returned invalid JSON");
111
+ }
112
+ return createResponsesCompactionResult(inputItems, generated);
113
+ }
@@ -0,0 +1,86 @@
1
+ export function isResponsesToolOutputItem(item) {
2
+ return item?.type === "function_call_output" || item?.type === "custom_tool_call_output";
3
+ }
4
+
5
+ const INLINE_IMAGE_DATA_URL = /^data:(image\/[a-z+.-]+);base64,(.+)$/i;
6
+
7
+ export function canonicalInlineImageIdentity(dataUrl) {
8
+ if (typeof dataUrl !== "string") return null;
9
+ const match = INLINE_IMAGE_DATA_URL.exec(dataUrl);
10
+ if (!match) return null;
11
+ const canonicalMime = match[1].toLowerCase();
12
+ return match[1] === canonicalMime
13
+ ? dataUrl
14
+ : `data:${canonicalMime};base64,${match[2]}`;
15
+ }
16
+
17
+ function imageUrlAccessor(part) {
18
+ if (!part || !["input_image", "image_url"].includes(part.type)) return null;
19
+ if (typeof part.image_url === "string") return { owner: part, key: "image_url" };
20
+ if (part.image_url && typeof part.image_url === "object" && typeof part.image_url.url === "string") {
21
+ return { owner: part.image_url, key: "url" };
22
+ }
23
+ if (part.type === "image_url" && typeof part.url === "string") return { owner: part, key: "url" };
24
+ if (part.type === "image_url" && part.url && typeof part.url === "object" && typeof part.url.url === "string") {
25
+ return { owner: part.url, key: "url" };
26
+ }
27
+ return null;
28
+ }
29
+
30
+ export function readResponsesImagePart(part) {
31
+ const accessor = imageUrlAccessor(part);
32
+ if (accessor) {
33
+ return {
34
+ get dataUrl() {
35
+ return accessor.owner[accessor.key];
36
+ },
37
+ get identity() {
38
+ return canonicalInlineImageIdentity(accessor.owner[accessor.key]);
39
+ },
40
+ setDataUrl(value) {
41
+ if (typeof value !== "string" || accessor.owner[accessor.key] === value) return false;
42
+ accessor.owner[accessor.key] = value;
43
+ return true;
44
+ },
45
+ };
46
+ }
47
+
48
+ if (part?.type !== "image" || part.source?.type !== "base64" || typeof part.source.data !== "string") {
49
+ return null;
50
+ }
51
+ return {
52
+ get dataUrl() {
53
+ return `data:${part.source.media_type || "image/png"};base64,${part.source.data}`;
54
+ },
55
+ get identity() {
56
+ return canonicalInlineImageIdentity(`data:${part.source.media_type || "image/png"};base64,${part.source.data}`);
57
+ },
58
+ setDataUrl(value) {
59
+ const match = typeof value === "string" ? INLINE_IMAGE_DATA_URL.exec(value) : null;
60
+ if (!match) return false;
61
+ const mediaType = match[1].toLowerCase();
62
+ const data = match[2];
63
+ if (part.source.media_type === mediaType && part.source.data === data) return false;
64
+ part.source.media_type = mediaType;
65
+ part.source.data = data;
66
+ return true;
67
+ },
68
+ };
69
+ }
70
+
71
+ export function readResponsesToolOutputParts(item) {
72
+ if (!isResponsesToolOutputItem(item)) return null;
73
+ if (Array.isArray(item.output)) return { parts: item.output, commit: null };
74
+ if (typeof item.output !== "string" || !item.output.trim().startsWith("[")) return null;
75
+
76
+ try {
77
+ const parts = JSON.parse(item.output);
78
+ if (!Array.isArray(parts)) return null;
79
+ return {
80
+ parts,
81
+ commit: () => { item.output = JSON.stringify(parts); },
82
+ };
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
@@ -13,11 +13,14 @@ import {
13
13
  modelSupportsChatCompletions,
14
14
  resolveOpenAIModel,
15
15
  } from "./models.mjs";
16
- import { chatToResponses, forwardToChat, responsesToChat } from "./responses-bridge.mjs";
16
+ import { chatToResponses, forwardToChat, responsesBodyUsesCustomTools } from "./responses-bridge.mjs";
17
+ import { prepareResponsesChatPayload } from "./responses-chat-payload.mjs";
18
+ import { prepareResponsesCompactionRequest } from "./responses-compaction.mjs";
17
19
  import { proxyCopilotResponses } from "./responses-proxy.mjs";
18
20
  import { prepareResponsesRequest, rememberResponseHistory } from "./responses-request.mjs";
19
21
  import { status } from "./status.mjs";
20
22
  import { endStreamWithError } from "./stream-errors.mjs";
23
+ import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
21
24
  import { recordResponsesUsage } from "./usage.mjs";
22
25
 
23
26
  const RESPONSES_ONLY_FALLBACK = new Set([
@@ -41,11 +44,34 @@ function isResponsesOnlyModel(model) {
41
44
  return RESPONSES_ONLY_FALLBACK.has(model);
42
45
  }
43
46
 
47
+ function cachedModelSupportsResponses(model) {
48
+ const endpoints = getCachedModelEndpoints(model);
49
+ return Array.isArray(endpoints)
50
+ && (endpoints.includes("/responses") || endpoints.includes("/v1/responses"));
51
+ }
52
+
53
+ function unsupportedCustomToolsError(model) {
54
+ const message = `Model ${model} has custom tool items that cannot be represented by Chat Completions, and cached endpoint metadata does not confirm Responses support`;
55
+ const error = new Error(message);
56
+ error.statusCode = 400;
57
+ error.code = "ccdx_custom_tools_require_responses";
58
+ error.jsonBody = {
59
+ error: {
60
+ message,
61
+ type: "invalid_request_error",
62
+ code: error.code,
63
+ model,
64
+ },
65
+ };
66
+ return error;
67
+ }
68
+
44
69
  export function createResponsesHandler(options) {
45
70
  const {
46
71
  acquireRequest,
47
72
  chatCompletionsFn,
48
73
  openAIModelEnv,
74
+ responsesPayloadOptions,
49
75
  responsesFn,
50
76
  streamHandshakeTimeoutMs,
51
77
  streamIdleTimeoutMs,
@@ -69,7 +95,12 @@ export function createResponsesHandler(options) {
69
95
  if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
70
96
  const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
71
97
  console.log(status("info", `responses model=${requestedModel}${upstreamLog} stream=${!!parsed.stream}`));
72
- if (requestedModel === CODEX_AUTO_REVIEW_MODEL || isResponsesOnlyModel(upstreamModel)) {
98
+ const usesCustomTools = responsesBodyUsesCustomTools(prepared.body);
99
+ const useNativeResponses = requestedModel === CODEX_AUTO_REVIEW_MODEL
100
+ || isResponsesOnlyModel(upstreamModel)
101
+ || (usesCustomTools && cachedModelSupportsResponses(upstreamModel));
102
+ if (usesCustomTools && !useNativeResponses) throw unsupportedCustomToolsError(upstreamModel);
103
+ if (useNativeResponses) {
73
104
  await proxyCopilotResponses(prepared, req, res, responsesFn, {
74
105
  signal: abort.signal,
75
106
  abort,
@@ -77,34 +108,52 @@ export function createResponsesHandler(options) {
77
108
  streamIdleTimeoutMs,
78
109
  });
79
110
  } else {
80
- const chatReq = responsesToChat(prepared.body);
111
+ const chatPayload = await prepareResponsesChatPayload(prepared, {
112
+ payloadOptions: responsesPayloadOptions,
113
+ signal: abort.signal,
114
+ stream: parsed.stream,
115
+ });
116
+ const { chatReq, bodyText } = chatPayload;
81
117
  if (parsed.stream) {
118
+ let streamResponseHeaders = null;
82
119
  await forwardToChat(chatReq, async (event, data) => {
83
- if (!res.headersSent) res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
120
+ if (!res.headersSent) res.writeHead(200, {
121
+ ...streamResponseHeaders,
122
+ "Cache-Control": "no-cache",
123
+ Connection: "keep-alive",
124
+ });
84
125
  await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
85
126
  if (event === "response.completed") {
86
127
  rememberResponseHistory(prepared, data.response);
87
128
  recordResponsesUsage({ surface: prepared.surface, mode: "stream", model, response: data.response, event: data });
88
129
  }
89
- }, () => { if (!res.writableEnded) res.end(); }, async (statusCode, errMsg) => {
130
+ }, () => { if (!res.writableEnded) res.end(); }, async (statusCode, errMsg, error, upstreamResponse) => {
90
131
  if (!res.headersSent) {
91
- res.writeHead(statusCode || 500, { "Content-Type": "text/plain; charset=utf-8" });
92
- res.end(errMsg);
132
+ if (upstreamResponse) {
133
+ sendUpstreamError(res, upstreamResponse, errMsg);
134
+ return;
135
+ }
136
+ const responseError = error || Object.assign(new Error(errMsg), { statusCode });
137
+ sendJsonError(res, responseError, statusCode || 500);
93
138
  return;
94
139
  }
95
- await endStreamWithError(res, "responses", new Error(errMsg), abort);
140
+ await endStreamWithError(res, "responses", error || new Error(errMsg), abort);
96
141
  }, {
97
142
  signal: abort.signal,
98
143
  abort,
99
144
  streamIdleTimeoutMs,
100
145
  chatCompletionsFn,
101
146
  releaseRequest,
147
+ bodyText,
148
+ onUpstreamResponse: (upstreamResponse) => {
149
+ streamResponseHeaders = safeUpstreamResponseHeaders(upstreamResponse.headers, {
150
+ contentType: "text/event-stream",
151
+ });
152
+ },
102
153
  });
103
154
  } else {
104
- chatReq.stream = false;
105
- delete chatReq.max_tokens;
106
155
  try {
107
- const upstream = await chatCompletionsFn({ ...chatReq, stream: false }, { signal: abort.signal });
156
+ const upstream = await chatCompletionsFn(chatReq, { signal: abort.signal, bodyText });
108
157
  releaseRequest();
109
158
  const data = await upstream.text();
110
159
  if (!upstream.ok) {
@@ -114,7 +163,9 @@ export function createResponsesHandler(options) {
114
163
  const response = chatToResponses(JSON.parse(data), model);
115
164
  rememberResponseHistory(prepared, response);
116
165
  recordResponsesUsage({ surface: prepared.surface, mode: "json", model, response, event: response });
117
- res.writeHead(200, { "Content-Type": "application/json" });
166
+ res.writeHead(200, safeUpstreamResponseHeaders(upstream.headers, {
167
+ contentType: "application/json",
168
+ }));
118
169
  res.end(JSON.stringify(response));
119
170
  } catch (error) {
120
171
  logRequestFailure("Responses", error, abort);
@@ -137,8 +188,6 @@ export function createResponsesCompactHandler(options) {
137
188
  acquireRequest,
138
189
  openAIModelEnv,
139
190
  responsesCompactFn,
140
- streamHandshakeTimeoutMs,
141
- streamIdleTimeoutMs,
142
191
  upstreamTimeoutMs,
143
192
  } = options;
144
193
 
@@ -148,22 +197,20 @@ export function createResponsesCompactHandler(options) {
148
197
  try {
149
198
  releaseRequest = await acquireRequest(req, { signal: abort.signal });
150
199
  const parsed = await readJsonBody(req);
151
- abort.setTimeout(
152
- parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
153
- parsed.stream ? "stream_handshake_timeout" : "upstream_timeout",
200
+ abort.setTimeout(upstreamTimeoutMs, "upstream_timeout");
201
+ const prepared = prepareResponsesCompactionRequest(
202
+ prepareResponsesRequest(parsed, { mutate: true }),
154
203
  );
155
- const prepared = prepareResponsesRequest(parsed, { mutate: true });
156
204
  prepared.surface = "responses_compact";
157
205
  const model = parsed.model || "unknown";
158
206
  const { requestedModel, upstreamModel } = resolveOpenAIModel(model, openAIModelEnv);
159
207
  if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
160
208
  const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
161
- console.log(status("info", `responses compact model=${requestedModel}${upstreamLog} stream=${!!parsed.stream}`));
209
+ console.log(status("info", `responses compact model=${requestedModel}${upstreamLog} stream=false`));
162
210
  await proxyCopilotResponses(prepared, req, res, responsesCompactFn, {
163
211
  signal: abort.signal,
164
212
  abort,
165
213
  releaseRequest,
166
- streamIdleTimeoutMs,
167
214
  });
168
215
  } catch (error) {
169
216
  logRequestFailure("Responses compact", error, abort);
@@ -1,3 +1,5 @@
1
+ import { readResponsesImagePart, readResponsesToolOutputParts } from "./responses-content.mjs";
2
+
1
3
  export const MAX_UPSTREAM_IMAGES = 50;
2
4
 
3
5
  function isImagePart(part) {
@@ -8,13 +10,13 @@ function isImagePart(part) {
8
10
  function imageIdentity(part) {
9
11
  if (!isImagePart(part)) return null;
10
12
 
13
+ const inlineIdentity = readResponsesImagePart(part)?.identity;
14
+ if (inlineIdentity) return { kind: "inline:data", value: inlineIdentity };
15
+
11
16
  if (part.type === "image") {
12
17
  const source = part.source;
13
- if (typeof source?.data === "string") {
14
- return { kind: `image:${source.type || "base64"}:${source.media_type || ""}`, value: source.data };
15
- }
16
18
  if (typeof source?.url === "string") {
17
- return { kind: `image:url:${part.detail || ""}`, value: source.url };
19
+ return { kind: `url:${part.detail || ""}`, value: source.url };
18
20
  }
19
21
  }
20
22
 
@@ -22,10 +24,10 @@ function imageIdentity(part) {
22
24
  const url = typeof raw === "string" ? raw : raw?.url;
23
25
  if (typeof url === "string") {
24
26
  const detail = part.detail ?? raw?.detail ?? "";
25
- return { kind: `${part.type}:url:${detail}`, value: url };
27
+ return { kind: `url:${detail}`, value: url };
26
28
  }
27
29
  if (typeof part.file_id === "string") {
28
- return { kind: `${part.type}:file`, value: part.file_id };
30
+ return { kind: "file", value: part.file_id };
29
31
  }
30
32
  return null;
31
33
  }
@@ -100,26 +102,13 @@ function collectImages(inputItems) {
100
102
  });
101
103
  }
102
104
 
103
- if (item.type !== "function_call_output") continue;
104
- if (Array.isArray(item.output)) {
105
- addPartContainer(records, refs, item.output, {
106
- kind: "tool-output",
107
- itemIndex,
108
- });
109
- continue;
110
- }
111
- if (typeof item.output !== "string" || !item.output.trim().startsWith("[")) continue;
112
- try {
113
- const parsed = JSON.parse(item.output);
114
- if (!Array.isArray(parsed)) continue;
115
- addPartContainer(records, refs, parsed, {
116
- kind: "tool-output",
117
- itemIndex,
118
- commit: () => { item.output = JSON.stringify(parsed); },
119
- });
120
- } catch {
121
- // Preserve non-JSON tool output unchanged.
122
- }
105
+ const toolOutput = readResponsesToolOutputParts(item);
106
+ if (!toolOutput) continue;
107
+ addPartContainer(records, refs, toolOutput.parts, {
108
+ kind: "tool-output",
109
+ itemIndex,
110
+ commit: toolOutput.commit,
111
+ });
123
112
  }
124
113
 
125
114
  if (hasTopLevelImages) records.push(topLevelRecord);
@@ -6,33 +6,82 @@ import {
6
6
  writeOrDrain,
7
7
  } from "./http-transport.mjs";
8
8
  import { openCopilotResponse, rememberResponseHistory } from "./responses-request.mjs";
9
+ import {
10
+ compactionInputWithoutTrigger,
11
+ parseResponsesCompactionResult,
12
+ } from "./responses-compaction.mjs";
9
13
  import { loadRuntimeConfig } from "./runtime-config.mjs";
10
14
  import { endStreamWithError } from "./stream-errors.mjs";
15
+ import {
16
+ incompleteUpstreamStream,
17
+ invalidUpstreamStream,
18
+ requireUpstreamEventStream,
19
+ ToolArgumentDeltaGuard,
20
+ } from "./stream-contract.mjs";
21
+ import {
22
+ isResponsesOutputEvent,
23
+ markFirstOutput,
24
+ markOutputTokens,
25
+ markStreamFailure,
26
+ } from "./stream-performance.mjs";
27
+ import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
11
28
  import { recordResponsesUsage } from "./usage.mjs";
12
29
 
13
30
  const MAX_SSE_BUFFER_BYTES = loadRuntimeConfig().maxSseBufferBytes;
31
+ const RESPONSES_TERMINAL_EVENT_TYPES = new Set([
32
+ "response.completed",
33
+ "response.incomplete",
34
+ "response.failed",
35
+ "error",
36
+ ]);
14
37
 
15
- function storeCompletedResponseFromSse(reqContext, data) {
16
- if (!data || data === "[DONE]") return;
38
+ function inspectResponseSseEvent(state, eventName, data) {
39
+ if (!data || state.sawTerminal) return;
40
+ if (data === "[DONE]") {
41
+ throw invalidUpstreamStream(
42
+ "Upstream sent [DONE] before a terminal Responses event",
43
+ "upstream_stream_terminal_missing",
44
+ );
45
+ }
46
+ let event;
17
47
  try {
18
- const event = JSON.parse(data);
19
- const response = event.response;
20
- if (response?.id && response.status === "completed") {
21
- rememberResponseHistory(reqContext, response);
22
- recordResponsesUsage({
23
- surface: reqContext.surface,
24
- mode: "stream",
25
- model: reqContext.body?.model,
26
- response,
27
- event,
28
- });
29
- }
48
+ event = JSON.parse(data);
30
49
  } catch {
31
- // Ignore non-JSON stream fragments.
50
+ throw invalidUpstreamStream(
51
+ "Upstream Responses SSE event contained invalid JSON",
52
+ "upstream_stream_json_invalid",
53
+ );
54
+ }
55
+ const eventType = event.type || eventName;
56
+ const outputTokens = event.response?.usage?.output_tokens ?? event.usage?.output_tokens;
57
+ if (outputTokens !== undefined) markOutputTokens(outputTokens);
58
+ if (!state.sawOutput && isResponsesOutputEvent(event, eventType)) {
59
+ state.sawOutput = true;
60
+ markFirstOutput();
61
+ }
62
+ if (["response.function_call_arguments.delta", "response.custom_tool_call_input.delta"].includes(eventType)) {
63
+ state.toolArgumentGuard.observe(event.output_index ?? event.item_id ?? event.call_id, event.delta);
32
64
  }
65
+ if (!RESPONSES_TERMINAL_EVENT_TYPES.has(eventType)) return;
66
+ const expectedStatus = {
67
+ "response.completed": "completed",
68
+ "response.incomplete": "incomplete",
69
+ "response.failed": "failed",
70
+ }[eventType];
71
+ if (expectedStatus && (!event.response?.id || event.response.status !== expectedStatus)) {
72
+ throw invalidUpstreamStream(
73
+ `Upstream ${eventType} event contained an invalid response resource`,
74
+ "upstream_stream_terminal_invalid",
75
+ );
76
+ }
77
+ state.sawTerminal = true;
78
+ if (eventType === "response.failed" || eventType === "error") markStreamFailure();
79
+ if (eventType !== "response.completed") return;
80
+ const response = event.response;
81
+ state.completed = { response, event };
33
82
  }
34
83
 
35
- function readSseEvents(buffer, onData) {
84
+ function readSseEvents(buffer, onEvent) {
36
85
  while (true) {
37
86
  const match = buffer.match(/\r?\n\r?\n/);
38
87
  if (!match) return buffer;
@@ -41,15 +90,31 @@ function readSseEvents(buffer, onData) {
41
90
  throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
42
91
  }
43
92
  buffer = buffer.slice(match.index + match[0].length);
44
- const data = chunk
45
- .split(/\r?\n/)
93
+ const lines = chunk.split(/\r?\n/);
94
+ const eventName = lines
95
+ .find((line) => line.startsWith("event:"))
96
+ ?.slice(6)
97
+ .trim();
98
+ const data = lines
46
99
  .filter((line) => line.startsWith("data:"))
47
100
  .map((line) => line.slice(5).trimStart())
48
101
  .join("\n");
49
- if (data) onData(data);
102
+ if (data) onEvent(eventName, data);
50
103
  }
51
104
  }
52
105
 
106
+ function storeCompletedResponse(reqContext, completed) {
107
+ if (!completed) return;
108
+ rememberResponseHistory(reqContext, completed.response);
109
+ recordResponsesUsage({
110
+ surface: reqContext.surface,
111
+ mode: "stream",
112
+ model: reqContext.body?.model,
113
+ response: completed.response,
114
+ event: completed.event,
115
+ });
116
+ }
117
+
53
118
  export async function proxyCopilotResponses(reqContext, req, res, upstream = copilotResponses, options = {}) {
54
119
  let opened;
55
120
  try {
@@ -65,15 +130,22 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
65
130
  }
66
131
 
67
132
  if (reqContext.body.stream) {
133
+ await requireUpstreamEventStream(resp);
68
134
  options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
69
135
  res.writeHead(resp.status, {
70
- "Content-Type": resp.headers.get("content-type") || "text/event-stream",
136
+ ...safeUpstreamResponseHeaders(resp.headers, { defaultContentType: "text/event-stream" }),
71
137
  "Cache-Control": "no-cache",
72
138
  Connection: "keep-alive",
73
139
  });
74
140
  const reader = resp.body.getReader();
75
141
  const decoder = new TextDecoder();
76
142
  let buffer = "";
143
+ const streamState = {
144
+ completed: null,
145
+ sawTerminal: false,
146
+ sawOutput: false,
147
+ toolArgumentGuard: new ToolArgumentDeltaGuard(),
148
+ };
77
149
  try {
78
150
  while (true) {
79
151
  const { done, value } = await reader.read();
@@ -82,17 +154,24 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
82
154
  if (Buffer.byteLength(buffer) > MAX_SSE_BUFFER_BYTES) {
83
155
  throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
84
156
  }
85
- readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
157
+ readSseEvents(buffer, (eventName, data) => inspectResponseSseEvent(streamState, eventName, data));
158
+ if (!streamState.sawTerminal) throw incompleteUpstreamStream("a terminal Responses event");
159
+ storeCompletedResponse(reqContext, streamState.completed);
86
160
  res.end();
87
161
  return;
88
162
  }
89
163
  options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
90
164
  buffer += decoder.decode(value, { stream: true });
91
- buffer = readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
165
+ buffer = readSseEvents(buffer, (eventName, data) => inspectResponseSseEvent(streamState, eventName, data));
92
166
  if (Buffer.byteLength(buffer) > MAX_SSE_BUFFER_BYTES) {
93
167
  throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
94
168
  }
95
169
  if (!await writeOrDrain(res, value)) return;
170
+ if (streamState.sawTerminal) {
171
+ storeCompletedResponse(reqContext, streamState.completed);
172
+ res.end();
173
+ return;
174
+ }
96
175
  }
97
176
  } catch (e) {
98
177
  logRequestFailure("Responses", e, options.abort);
@@ -103,7 +182,28 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
103
182
  }
104
183
  } else {
105
184
  const data = await resp.text();
106
- res.writeHead(resp.status, { "Content-Type": "application/json" });
185
+ if (resp.ok && reqContext.surface === "responses_compact") {
186
+ const response = parseResponsesCompactionResult(
187
+ compactionInputWithoutTrigger(reqContext.body.input),
188
+ data,
189
+ );
190
+ rememberResponseHistory(reqContext, response);
191
+ recordResponsesUsage({
192
+ surface: reqContext.surface,
193
+ mode: "json",
194
+ model: reqContext.body?.model,
195
+ response,
196
+ event: response,
197
+ });
198
+ res.writeHead(resp.status, safeUpstreamResponseHeaders(resp.headers, {
199
+ contentType: "application/json",
200
+ }));
201
+ res.end(JSON.stringify(response));
202
+ return;
203
+ }
204
+ res.writeHead(resp.status, safeUpstreamResponseHeaders(resp.headers, {
205
+ contentType: "application/json",
206
+ }));
107
207
  res.end(data);
108
208
  if (resp.ok) {
109
209
  try {