codex-copilot-dx 0.4.43 → 0.5.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/README.md +31 -13
- package/bin/cli.mjs +31 -7
- package/bin/codex-copilot-dx.mjs +2 -0
- package/package.json +3 -2
- package/src/adapter.mjs +16 -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 +30 -6
- package/src/cli-status.mjs +122 -0
- package/src/copilot.mjs +11 -4
- package/src/doctor.mjs +11 -2
- package/src/http-transport.mjs +4 -1
- package/src/image-optimization.mjs +74 -65
- package/src/observability.mjs +4 -3
- package/src/responses-bridge.mjs +46 -10
- package/src/responses-chat-payload.mjs +2 -1
- package/src/responses-compaction.mjs +113 -0
- package/src/responses-handler.mjs +28 -14
- package/src/responses-proxy.mjs +123 -23
- package/src/running-adapter.mjs +1 -0
- 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
|
+
}
|
|
@@ -15,10 +15,12 @@ import {
|
|
|
15
15
|
} from "./models.mjs";
|
|
16
16
|
import { chatToResponses, forwardToChat, responsesBodyUsesCustomTools } from "./responses-bridge.mjs";
|
|
17
17
|
import { prepareResponsesChatPayload } from "./responses-chat-payload.mjs";
|
|
18
|
+
import { prepareResponsesCompactionRequest } from "./responses-compaction.mjs";
|
|
18
19
|
import { proxyCopilotResponses } from "./responses-proxy.mjs";
|
|
19
20
|
import { prepareResponsesRequest, rememberResponseHistory } from "./responses-request.mjs";
|
|
20
21
|
import { status } from "./status.mjs";
|
|
21
22
|
import { endStreamWithError } from "./stream-errors.mjs";
|
|
23
|
+
import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
|
|
22
24
|
import { recordResponsesUsage } from "./usage.mjs";
|
|
23
25
|
|
|
24
26
|
const RESPONSES_ONLY_FALLBACK = new Set([
|
|
@@ -113,20 +115,29 @@ export function createResponsesHandler(options) {
|
|
|
113
115
|
});
|
|
114
116
|
const { chatReq, bodyText } = chatPayload;
|
|
115
117
|
if (parsed.stream) {
|
|
118
|
+
let streamResponseHeaders = null;
|
|
116
119
|
await forwardToChat(chatReq, async (event, data) => {
|
|
117
|
-
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
|
+
});
|
|
118
125
|
await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
119
126
|
if (event === "response.completed") {
|
|
120
127
|
rememberResponseHistory(prepared, data.response);
|
|
121
128
|
recordResponsesUsage({ surface: prepared.surface, mode: "stream", model, response: data.response, event: data });
|
|
122
129
|
}
|
|
123
|
-
}, () => { if (!res.writableEnded) res.end(); }, async (statusCode, errMsg) => {
|
|
130
|
+
}, () => { if (!res.writableEnded) res.end(); }, async (statusCode, errMsg, error, upstreamResponse) => {
|
|
124
131
|
if (!res.headersSent) {
|
|
125
|
-
|
|
126
|
-
|
|
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);
|
|
127
138
|
return;
|
|
128
139
|
}
|
|
129
|
-
await endStreamWithError(res, "responses", new Error(errMsg), abort);
|
|
140
|
+
await endStreamWithError(res, "responses", error || new Error(errMsg), abort);
|
|
130
141
|
}, {
|
|
131
142
|
signal: abort.signal,
|
|
132
143
|
abort,
|
|
@@ -134,6 +145,11 @@ export function createResponsesHandler(options) {
|
|
|
134
145
|
chatCompletionsFn,
|
|
135
146
|
releaseRequest,
|
|
136
147
|
bodyText,
|
|
148
|
+
onUpstreamResponse: (upstreamResponse) => {
|
|
149
|
+
streamResponseHeaders = safeUpstreamResponseHeaders(upstreamResponse.headers, {
|
|
150
|
+
contentType: "text/event-stream",
|
|
151
|
+
});
|
|
152
|
+
},
|
|
137
153
|
});
|
|
138
154
|
} else {
|
|
139
155
|
try {
|
|
@@ -147,7 +163,9 @@ export function createResponsesHandler(options) {
|
|
|
147
163
|
const response = chatToResponses(JSON.parse(data), model);
|
|
148
164
|
rememberResponseHistory(prepared, response);
|
|
149
165
|
recordResponsesUsage({ surface: prepared.surface, mode: "json", model, response, event: response });
|
|
150
|
-
res.writeHead(200, {
|
|
166
|
+
res.writeHead(200, safeUpstreamResponseHeaders(upstream.headers, {
|
|
167
|
+
contentType: "application/json",
|
|
168
|
+
}));
|
|
151
169
|
res.end(JSON.stringify(response));
|
|
152
170
|
} catch (error) {
|
|
153
171
|
logRequestFailure("Responses", error, abort);
|
|
@@ -170,8 +188,6 @@ export function createResponsesCompactHandler(options) {
|
|
|
170
188
|
acquireRequest,
|
|
171
189
|
openAIModelEnv,
|
|
172
190
|
responsesCompactFn,
|
|
173
|
-
streamHandshakeTimeoutMs,
|
|
174
|
-
streamIdleTimeoutMs,
|
|
175
191
|
upstreamTimeoutMs,
|
|
176
192
|
} = options;
|
|
177
193
|
|
|
@@ -181,22 +197,20 @@ export function createResponsesCompactHandler(options) {
|
|
|
181
197
|
try {
|
|
182
198
|
releaseRequest = await acquireRequest(req, { signal: abort.signal });
|
|
183
199
|
const parsed = await readJsonBody(req);
|
|
184
|
-
abort.setTimeout(
|
|
185
|
-
|
|
186
|
-
parsed
|
|
200
|
+
abort.setTimeout(upstreamTimeoutMs, "upstream_timeout");
|
|
201
|
+
const prepared = prepareResponsesCompactionRequest(
|
|
202
|
+
prepareResponsesRequest(parsed, { mutate: true }),
|
|
187
203
|
);
|
|
188
|
-
const prepared = prepareResponsesRequest(parsed, { mutate: true });
|
|
189
204
|
prepared.surface = "responses_compact";
|
|
190
205
|
const model = parsed.model || "unknown";
|
|
191
206
|
const { requestedModel, upstreamModel } = resolveOpenAIModel(model, openAIModelEnv);
|
|
192
207
|
if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
|
|
193
208
|
const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
|
|
194
|
-
console.log(status("info", `responses compact model=${requestedModel}${upstreamLog} stream
|
|
209
|
+
console.log(status("info", `responses compact model=${requestedModel}${upstreamLog} stream=false`));
|
|
195
210
|
await proxyCopilotResponses(prepared, req, res, responsesCompactFn, {
|
|
196
211
|
signal: abort.signal,
|
|
197
212
|
abort,
|
|
198
213
|
releaseRequest,
|
|
199
|
-
streamIdleTimeoutMs,
|
|
200
214
|
});
|
|
201
215
|
} catch (error) {
|
|
202
216
|
logRequestFailure("Responses compact", error, abort);
|
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 {
|
package/src/running-adapter.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { localPackageVersion } from "./version.mjs";
|
|
3
3
|
|
|
4
4
|
export const ADAPTER_HEALTH_PATH = "/_ccdx/health";
|
|
5
|
+
export const ADAPTER_STATUS_PATH = "/_ccdx/status";
|
|
5
6
|
export const ADAPTER_PROTOCOL_VERSION = 2;
|
|
6
7
|
export const ADAPTER_VERSION = localPackageVersion();
|
|
7
8
|
const ADAPTER_INSTANCE_ID = randomUUID();
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const DEFAULT_BLANK_TOOL_ARGUMENT_LIMIT = 20;
|
|
2
|
+
|
|
3
|
+
function streamProtocolError(message, code) {
|
|
4
|
+
const error = new Error(message);
|
|
5
|
+
error.statusCode = 502;
|
|
6
|
+
error.code = code;
|
|
7
|
+
error.jsonBody = {
|
|
8
|
+
error: {
|
|
9
|
+
message,
|
|
10
|
+
type: "upstream_protocol_error",
|
|
11
|
+
code,
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
return error;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function invalidUpstreamStream(message, code = "upstream_stream_invalid") {
|
|
18
|
+
return streamProtocolError(message, code);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function withChatStreamUsage(request) {
|
|
22
|
+
const streamOptions = request?.stream_options && typeof request.stream_options === "object"
|
|
23
|
+
? request.stream_options
|
|
24
|
+
: {};
|
|
25
|
+
if (request?.stream === true && streamOptions.include_usage === true) return request;
|
|
26
|
+
return {
|
|
27
|
+
...request,
|
|
28
|
+
stream: true,
|
|
29
|
+
stream_options: { ...streamOptions, include_usage: true },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function requireUpstreamEventStream(response) {
|
|
34
|
+
let error = null;
|
|
35
|
+
if (!response?.body) {
|
|
36
|
+
error = streamProtocolError(
|
|
37
|
+
"Upstream returned a successful streaming response without a body",
|
|
38
|
+
"upstream_stream_body_missing",
|
|
39
|
+
);
|
|
40
|
+
} else {
|
|
41
|
+
const contentType = response.headers?.get?.("content-type") || "";
|
|
42
|
+
const mediaType = contentType.split(";", 1)[0].trim().toLowerCase();
|
|
43
|
+
if (mediaType !== "text/event-stream") {
|
|
44
|
+
error = streamProtocolError(
|
|
45
|
+
"Upstream returned a successful streaming response with a non-SSE Content-Type",
|
|
46
|
+
"upstream_stream_content_type_invalid",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!error) return;
|
|
52
|
+
await response?.body?.cancel?.().catch(() => {});
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function incompleteUpstreamStream(expectedTerminator) {
|
|
57
|
+
return streamProtocolError(
|
|
58
|
+
`Upstream stream ended before ${expectedTerminator}`,
|
|
59
|
+
"upstream_stream_incomplete",
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function upstreamChatStreamError(event) {
|
|
64
|
+
const payload = event?.error;
|
|
65
|
+
if (!payload || typeof payload !== "object") return null;
|
|
66
|
+
const type = typeof payload.type === "string" && payload.type ? `${payload.type}: ` : "";
|
|
67
|
+
const message = typeof payload.message === "string" && payload.message
|
|
68
|
+
? payload.message.slice(0, 500)
|
|
69
|
+
: "Upstream stream error";
|
|
70
|
+
return streamProtocolError(
|
|
71
|
+
`Upstream Chat Completions SSE error: ${type}${message}`,
|
|
72
|
+
"upstream_chat_stream_error",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class ToolArgumentDeltaGuard {
|
|
77
|
+
constructor(limit = DEFAULT_BLANK_TOOL_ARGUMENT_LIMIT) {
|
|
78
|
+
this.limit = limit;
|
|
79
|
+
this.whitespaceRuns = new Map();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
observe(callIndex, delta) {
|
|
83
|
+
if (typeof delta !== "string") return;
|
|
84
|
+
const key = callIndex ?? "unknown";
|
|
85
|
+
let count = this.whitespaceRuns.get(key) || 0;
|
|
86
|
+
for (const character of delta) {
|
|
87
|
+
if (character === "\r" || character === "\n" || character === "\t") {
|
|
88
|
+
count += 1;
|
|
89
|
+
if (count > this.limit) {
|
|
90
|
+
this.whitespaceRuns.set(key, count);
|
|
91
|
+
throw streamProtocolError(
|
|
92
|
+
`Upstream sent more than ${this.limit} consecutive line-break or tab characters in tool arguments`,
|
|
93
|
+
"upstream_tool_arguments_stalled",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
} else if (character !== " ") {
|
|
97
|
+
count = 0;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
this.whitespaceRuns.set(key, count);
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/stream-errors.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { writeOrDrain } from "./http-transport.mjs";
|
|
2
|
+
import { markStreamFailure } from "./stream-performance.mjs";
|
|
2
3
|
|
|
3
4
|
function streamErrorData(protocol, error, abort) {
|
|
4
|
-
const code = abort?.reason || "upstream_stream_error";
|
|
5
|
+
const code = abort?.reason || error?.code || "upstream_stream_error";
|
|
5
6
|
const message = `${code}: ${error?.message || "Upstream stream failed"}`;
|
|
6
7
|
if (protocol === "anthropic") {
|
|
7
8
|
return { type: "error", error: { type: "api_error", message } };
|
|
@@ -10,6 +11,7 @@ function streamErrorData(protocol, error, abort) {
|
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export async function endStreamWithError(res, protocol, error, abort) {
|
|
14
|
+
markStreamFailure();
|
|
13
15
|
if (res.destroyed || res.writableEnded) return;
|
|
14
16
|
const data = streamErrorData(protocol, error, abort);
|
|
15
17
|
await writeOrDrain(res, `event: error\ndata: ${JSON.stringify(data)}\n\n`).catch(() => false);
|