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.
- package/README.md +28 -16
- package/bin/cli.mjs +4 -4
- package/package.json +2 -1
- package/src/adapter.mjs +17 -8
- package/src/anthropic-handler.mjs +13 -5
- package/src/anthropic.mjs +40 -5
- package/src/auth.mjs +2 -2
- package/src/bounded-result-cache.mjs +180 -0
- package/src/claude-config.mjs +1 -1
- package/src/cli-options.mjs +7 -5
- package/src/copilot.mjs +42 -8
- package/src/doctor.mjs +11 -2
- package/src/http-transport.mjs +5 -2
- package/src/image-optimization.mjs +312 -116
- package/src/observability.mjs +2 -1
- package/src/responses-bridge.mjs +76 -20
- package/src/responses-byte-budget.mjs +314 -0
- package/src/responses-chat-payload.mjs +129 -0
- package/src/responses-compaction.mjs +113 -0
- package/src/responses-content.mjs +86 -0
- package/src/responses-handler.mjs +67 -20
- package/src/responses-image-limit.mjs +15 -26
- package/src/responses-proxy.mjs +123 -23
- package/src/responses-request.mjs +43 -6
- package/src/stream-contract.mjs +102 -0
- package/src/stream-errors.mjs +3 -1
- package/src/stream-performance.mjs +177 -0
- package/src/upstream-headers.mjs +67 -0
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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, {
|
|
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
|
-
|
|
92
|
-
|
|
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(
|
|
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, {
|
|
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
|
-
|
|
153
|
-
parsed
|
|
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
|
|
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: `
|
|
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:
|
|
27
|
+
return { kind: `url:${detail}`, value: url };
|
|
26
28
|
}
|
|
27
29
|
if (typeof part.file_id === "string") {
|
|
28
|
-
return { kind:
|
|
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
|
-
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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);
|
package/src/responses-proxy.mjs
CHANGED
|
@@ -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
|
|
16
|
-
if (!data ||
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
45
|
-
|
|
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)
|
|
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
|
-
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
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 {
|