copillm 0.1.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 +52 -0
- package/dist/agentconfig/apply.js +53 -0
- package/dist/agentconfig/load.js +163 -0
- package/dist/agentconfig/markerBlock.js +76 -0
- package/dist/agentconfig/render.js +317 -0
- package/dist/agentconfig/schema.js +65 -0
- package/dist/auth/copilotToken.js +122 -0
- package/dist/auth/credentials.js +221 -0
- package/dist/auth/deviceFlow.js +89 -0
- package/dist/auth/ensureAuthenticated.js +55 -0
- package/dist/auth/githubIdentity.js +42 -0
- package/dist/auth/interactivePrompt.js +135 -0
- package/dist/claude/cache.js +20 -0
- package/dist/claude/settingsConflict.js +85 -0
- package/dist/cli/agentEnv.js +56 -0
- package/dist/cli/configCommands.js +149 -0
- package/dist/cli/envBlock.js +43 -0
- package/dist/cli/launchAgent.js +59 -0
- package/dist/cli/resolveAgent.js +361 -0
- package/dist/cli.js +1178 -0
- package/dist/codex/init.js +93 -0
- package/dist/config/config.js +51 -0
- package/dist/config/fsSecurity.js +39 -0
- package/dist/config/home.js +62 -0
- package/dist/config/logging.js +33 -0
- package/dist/config/upstream.js +38 -0
- package/dist/models/anthropicDefaults.js +138 -0
- package/dist/models/discovery.js +208 -0
- package/dist/pi/init.js +174 -0
- package/dist/server/anthropicModelsResponse.js +151 -0
- package/dist/server/codexSchema.js +100 -0
- package/dist/server/debugInfo.js +48 -0
- package/dist/server/lock.js +150 -0
- package/dist/server/proxy.js +715 -0
- package/dist/translation/openaiAnthropic.js +391 -0
- package/dist/translation/streamingOpenAIToAnthropic.js +290 -0
- package/dist/types/index.js +1 -0
- package/package.json +50 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export class ProtocolTranslationError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "ProtocolTranslationError";
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function anthropicToOpenAI(body) {
|
|
11
|
+
const request = parseAnthropicRequest(body);
|
|
12
|
+
const messages = [];
|
|
13
|
+
const systemText = translateSystemToText(request.system);
|
|
14
|
+
if (systemText !== null) {
|
|
15
|
+
messages.push({ role: "system", content: systemText });
|
|
16
|
+
}
|
|
17
|
+
for (const message of request.messages) {
|
|
18
|
+
messages.push(...translateAnthropicMessage(message));
|
|
19
|
+
}
|
|
20
|
+
const translated = {
|
|
21
|
+
model: request.model,
|
|
22
|
+
messages,
|
|
23
|
+
max_tokens: request.max_tokens ?? 1024
|
|
24
|
+
};
|
|
25
|
+
if (request.temperature !== undefined) {
|
|
26
|
+
translated.temperature = request.temperature;
|
|
27
|
+
}
|
|
28
|
+
if (request.stream !== undefined) {
|
|
29
|
+
translated.stream = request.stream;
|
|
30
|
+
}
|
|
31
|
+
if (request.tools && request.tools.length > 0) {
|
|
32
|
+
translated.tools = request.tools.map(translateToolDefinition);
|
|
33
|
+
}
|
|
34
|
+
if (request.tool_choice) {
|
|
35
|
+
translated.tool_choice = translateToolChoice(request.tool_choice);
|
|
36
|
+
}
|
|
37
|
+
return translated;
|
|
38
|
+
}
|
|
39
|
+
export function openAIToAnthropic(body) {
|
|
40
|
+
const payload = parseOpenAIResponse(body);
|
|
41
|
+
if (payload.choices.length !== 1) {
|
|
42
|
+
throw new ProtocolTranslationError("unsupported_multiple_choices", "OpenAI response has multiple choices; Anthropic response translation requires exactly one choice.");
|
|
43
|
+
}
|
|
44
|
+
const [choice] = payload.choices;
|
|
45
|
+
if (!choice.message) {
|
|
46
|
+
throw new ProtocolTranslationError("missing_choice_message", "OpenAI response choice is missing message content.");
|
|
47
|
+
}
|
|
48
|
+
const messageBlocks = translateOpenAIMessageBlocks(choice.message);
|
|
49
|
+
return {
|
|
50
|
+
id: deriveAnthropicMessageId(payload.id, payload),
|
|
51
|
+
type: "message",
|
|
52
|
+
role: "assistant",
|
|
53
|
+
model: payload.model,
|
|
54
|
+
content: messageBlocks,
|
|
55
|
+
stop_reason: mapFinishReason(choice.finish_reason),
|
|
56
|
+
stop_sequence: null,
|
|
57
|
+
usage: {
|
|
58
|
+
input_tokens: payload.usage?.prompt_tokens ?? 0,
|
|
59
|
+
output_tokens: payload.usage?.completion_tokens ?? 0,
|
|
60
|
+
cache_read_input_tokens: payload.usage?.prompt_tokens_details?.cached_tokens ?? 0
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The `[1m]` suffix copillm appends to advertised model ids in
|
|
66
|
+
* `/anthropic/v1/models` so Claude Code unlocks its 1M-class autocompact
|
|
67
|
+
* budget for >=1M-context models. Upstream Copilot has never heard of the
|
|
68
|
+
* suffix and must always see the canonical id, so we strip it from any
|
|
69
|
+
* incoming request body before validation or translation. See
|
|
70
|
+
* `applyOneMillionAlias()` in `src/server/anthropicModelsResponse.ts`.
|
|
71
|
+
*/
|
|
72
|
+
export const ONE_M_ALIAS_SUFFIX = "[1m]";
|
|
73
|
+
export function stripOneMillionAlias(model) {
|
|
74
|
+
return model.endsWith(ONE_M_ALIAS_SUFFIX)
|
|
75
|
+
? model.slice(0, model.length - ONE_M_ALIAS_SUFFIX.length)
|
|
76
|
+
: model;
|
|
77
|
+
}
|
|
78
|
+
function parseAnthropicRequest(body) {
|
|
79
|
+
if (!isObject(body)) {
|
|
80
|
+
throw new ProtocolTranslationError("invalid_request", "Anthropic request body must be an object.");
|
|
81
|
+
}
|
|
82
|
+
if (typeof body.model !== "string" || body.model.length === 0) {
|
|
83
|
+
throw new ProtocolTranslationError("invalid_model", "Anthropic request model must be a non-empty string.");
|
|
84
|
+
}
|
|
85
|
+
const normalisedModel = stripOneMillionAlias(body.model);
|
|
86
|
+
if (normalisedModel.length === 0) {
|
|
87
|
+
throw new ProtocolTranslationError("invalid_model", "Anthropic request model must be a non-empty string.");
|
|
88
|
+
}
|
|
89
|
+
if (!Array.isArray(body.messages)) {
|
|
90
|
+
throw new ProtocolTranslationError("invalid_messages", "Anthropic request messages must be an array.");
|
|
91
|
+
}
|
|
92
|
+
const parsed = {
|
|
93
|
+
model: normalisedModel,
|
|
94
|
+
messages: body.messages
|
|
95
|
+
};
|
|
96
|
+
if (body.system !== undefined) {
|
|
97
|
+
parsed.system = body.system;
|
|
98
|
+
}
|
|
99
|
+
if (body.max_tokens !== undefined) {
|
|
100
|
+
parsed.max_tokens = body.max_tokens;
|
|
101
|
+
}
|
|
102
|
+
if (body.temperature !== undefined) {
|
|
103
|
+
parsed.temperature = body.temperature;
|
|
104
|
+
}
|
|
105
|
+
if (body.stream !== undefined) {
|
|
106
|
+
parsed.stream = body.stream;
|
|
107
|
+
}
|
|
108
|
+
if (body.tools !== undefined) {
|
|
109
|
+
parsed.tools = body.tools;
|
|
110
|
+
}
|
|
111
|
+
if (body.tool_choice !== undefined) {
|
|
112
|
+
parsed.tool_choice = body.tool_choice;
|
|
113
|
+
}
|
|
114
|
+
return parsed;
|
|
115
|
+
}
|
|
116
|
+
function parseOpenAIResponse(body) {
|
|
117
|
+
if (!isObject(body)) {
|
|
118
|
+
throw new ProtocolTranslationError("invalid_response", "OpenAI response body must be an object.");
|
|
119
|
+
}
|
|
120
|
+
if (!Array.isArray(body.choices)) {
|
|
121
|
+
throw new ProtocolTranslationError("missing_choices", "OpenAI response choices must be an array.");
|
|
122
|
+
}
|
|
123
|
+
const parsed = {
|
|
124
|
+
...body,
|
|
125
|
+
choices: body.choices
|
|
126
|
+
};
|
|
127
|
+
return parsed;
|
|
128
|
+
}
|
|
129
|
+
function translateSystemToText(system) {
|
|
130
|
+
if (system === undefined) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
if (typeof system === "string") {
|
|
134
|
+
return system;
|
|
135
|
+
}
|
|
136
|
+
if (!Array.isArray(system)) {
|
|
137
|
+
throw new ProtocolTranslationError("invalid_system", "Anthropic system prompt must be a string or text blocks.");
|
|
138
|
+
}
|
|
139
|
+
return joinTextBlocks(system, "Anthropic system prompt");
|
|
140
|
+
}
|
|
141
|
+
function translateAnthropicMessage(message) {
|
|
142
|
+
if (message.role !== "assistant" && message.role !== "user") {
|
|
143
|
+
throw new ProtocolTranslationError("unsupported_message_role", `Unsupported Anthropic message role: ${String(message.role)}.`);
|
|
144
|
+
}
|
|
145
|
+
if (typeof message.content === "string") {
|
|
146
|
+
return [{ role: message.role, content: message.content }];
|
|
147
|
+
}
|
|
148
|
+
if (!Array.isArray(message.content)) {
|
|
149
|
+
throw new ProtocolTranslationError("invalid_message_content", "Anthropic message content must be a string or array.");
|
|
150
|
+
}
|
|
151
|
+
return message.role === "assistant"
|
|
152
|
+
? translateAssistantMessageBlocks(message.content)
|
|
153
|
+
: translateUserMessageBlocks(message.content);
|
|
154
|
+
}
|
|
155
|
+
function translateAssistantMessageBlocks(blocks) {
|
|
156
|
+
const textSegments = [];
|
|
157
|
+
const toolCalls = [];
|
|
158
|
+
for (const block of blocks) {
|
|
159
|
+
if (!isObject(block) || typeof block.type !== "string") {
|
|
160
|
+
throw new ProtocolTranslationError("invalid_block", "Anthropic assistant message contains an invalid content block.");
|
|
161
|
+
}
|
|
162
|
+
if (block.type === "text") {
|
|
163
|
+
if (typeof block.text !== "string") {
|
|
164
|
+
throw new ProtocolTranslationError("invalid_text_block", "Anthropic text block must include string text.");
|
|
165
|
+
}
|
|
166
|
+
textSegments.push(block.text);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (block.type === "tool_use") {
|
|
170
|
+
if (typeof block.id !== "string" || typeof block.name !== "string") {
|
|
171
|
+
throw new ProtocolTranslationError("invalid_tool_use", "Anthropic tool_use block requires id and name.");
|
|
172
|
+
}
|
|
173
|
+
toolCalls.push({
|
|
174
|
+
id: block.id,
|
|
175
|
+
type: "function",
|
|
176
|
+
function: {
|
|
177
|
+
name: block.name,
|
|
178
|
+
arguments: JSON.stringify(block.input ?? {})
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (block.type === "tool_result") {
|
|
184
|
+
throw new ProtocolTranslationError("invalid_tool_result_role", "Anthropic tool_result blocks are only supported in user messages.");
|
|
185
|
+
}
|
|
186
|
+
throw new ProtocolTranslationError("unsupported_block", `Unsupported Anthropic assistant block type: ${block.type}.`);
|
|
187
|
+
}
|
|
188
|
+
return [
|
|
189
|
+
{
|
|
190
|
+
role: "assistant",
|
|
191
|
+
content: textSegments.length > 0 ? textSegments.join("\n") : null,
|
|
192
|
+
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {})
|
|
193
|
+
}
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
function translateUserMessageBlocks(blocks) {
|
|
197
|
+
const translated = [];
|
|
198
|
+
const textSegments = [];
|
|
199
|
+
const flushText = () => {
|
|
200
|
+
if (textSegments.length > 0) {
|
|
201
|
+
translated.push({ role: "user", content: textSegments.join("\n") });
|
|
202
|
+
textSegments.length = 0;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
for (const block of blocks) {
|
|
206
|
+
if (!isObject(block) || typeof block.type !== "string") {
|
|
207
|
+
throw new ProtocolTranslationError("invalid_block", "Anthropic user message contains an invalid content block.");
|
|
208
|
+
}
|
|
209
|
+
if (block.type === "text") {
|
|
210
|
+
if (typeof block.text !== "string") {
|
|
211
|
+
throw new ProtocolTranslationError("invalid_text_block", "Anthropic text block must include string text.");
|
|
212
|
+
}
|
|
213
|
+
textSegments.push(block.text);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (block.type === "tool_result") {
|
|
217
|
+
if (typeof block.tool_use_id !== "string" || block.tool_use_id.length === 0) {
|
|
218
|
+
throw new ProtocolTranslationError("invalid_tool_result", "Anthropic tool_result requires non-empty tool_use_id.");
|
|
219
|
+
}
|
|
220
|
+
flushText();
|
|
221
|
+
const rawContent = translateToolResultContent(block.content);
|
|
222
|
+
const content = block.is_error ? `[tool_error] ${rawContent}` : rawContent;
|
|
223
|
+
translated.push({
|
|
224
|
+
role: "tool",
|
|
225
|
+
tool_call_id: block.tool_use_id,
|
|
226
|
+
content
|
|
227
|
+
});
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (block.type === "tool_use") {
|
|
231
|
+
throw new ProtocolTranslationError("invalid_tool_use_role", "Anthropic tool_use blocks are only supported in assistant messages.");
|
|
232
|
+
}
|
|
233
|
+
throw new ProtocolTranslationError("unsupported_block", `Unsupported Anthropic user block type: ${block.type}.`);
|
|
234
|
+
}
|
|
235
|
+
flushText();
|
|
236
|
+
if (translated.length === 0) {
|
|
237
|
+
translated.push({ role: "user", content: "" });
|
|
238
|
+
}
|
|
239
|
+
return translated;
|
|
240
|
+
}
|
|
241
|
+
function translateToolResultContent(content) {
|
|
242
|
+
if (typeof content === "string") {
|
|
243
|
+
return content;
|
|
244
|
+
}
|
|
245
|
+
if (!Array.isArray(content)) {
|
|
246
|
+
throw new ProtocolTranslationError("invalid_tool_result_content", "Anthropic tool_result content must be string or text blocks.");
|
|
247
|
+
}
|
|
248
|
+
return joinTextBlocks(content, "Anthropic tool_result content");
|
|
249
|
+
}
|
|
250
|
+
function translateToolDefinition(tool) {
|
|
251
|
+
if (!isObject(tool) || typeof tool.name !== "string" || tool.name.length === 0) {
|
|
252
|
+
throw new ProtocolTranslationError("invalid_tool_definition", "Anthropic tool definitions require a non-empty name.");
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
type: "function",
|
|
256
|
+
function: {
|
|
257
|
+
name: tool.name,
|
|
258
|
+
...(typeof tool.description === "string" ? { description: tool.description } : {}),
|
|
259
|
+
parameters: isObject(tool.input_schema) ? tool.input_schema : { type: "object", properties: {} }
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function translateToolChoice(toolChoice) {
|
|
264
|
+
if (!isObject(toolChoice) || typeof toolChoice.type !== "string") {
|
|
265
|
+
throw new ProtocolTranslationError("invalid_tool_choice", "Anthropic tool_choice must be an object with a type.");
|
|
266
|
+
}
|
|
267
|
+
switch (toolChoice.type) {
|
|
268
|
+
case "auto":
|
|
269
|
+
return "auto";
|
|
270
|
+
case "any":
|
|
271
|
+
return "required";
|
|
272
|
+
case "tool":
|
|
273
|
+
if (typeof toolChoice.name !== "string" || toolChoice.name.length === 0) {
|
|
274
|
+
throw new ProtocolTranslationError("invalid_tool_choice", "Anthropic tool_choice.type=tool requires a name.");
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
type: "function",
|
|
278
|
+
function: {
|
|
279
|
+
name: toolChoice.name
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
case "none":
|
|
283
|
+
return "none";
|
|
284
|
+
default:
|
|
285
|
+
throw new ProtocolTranslationError("unsupported_tool_choice", `Unsupported Anthropic tool_choice type: ${toolChoice.type}.`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function joinTextBlocks(blocks, contextLabel) {
|
|
289
|
+
return blocks
|
|
290
|
+
.map((block) => {
|
|
291
|
+
if (!isObject(block) || block.type !== "text" || typeof block.text !== "string") {
|
|
292
|
+
throw new ProtocolTranslationError("unsupported_block", `${contextLabel} only supports text blocks.`);
|
|
293
|
+
}
|
|
294
|
+
return block.text;
|
|
295
|
+
})
|
|
296
|
+
.join("\n");
|
|
297
|
+
}
|
|
298
|
+
function translateOpenAIMessageBlocks(message) {
|
|
299
|
+
if (message.role !== undefined && message.role !== "assistant") {
|
|
300
|
+
throw new ProtocolTranslationError("unsupported_message_role", `OpenAI response message role ${String(message.role)} cannot be translated to Anthropic assistant response.`);
|
|
301
|
+
}
|
|
302
|
+
const blocks = [];
|
|
303
|
+
blocks.push(...translateOpenAIContentToAnthropicBlocks(message.content));
|
|
304
|
+
if (message.tool_calls !== undefined) {
|
|
305
|
+
if (!Array.isArray(message.tool_calls)) {
|
|
306
|
+
throw new ProtocolTranslationError("invalid_tool_calls", "OpenAI tool_calls must be an array when present.");
|
|
307
|
+
}
|
|
308
|
+
for (const toolCall of message.tool_calls) {
|
|
309
|
+
if (!isObject(toolCall) || typeof toolCall.id !== "string" || !isObject(toolCall.function)) {
|
|
310
|
+
throw new ProtocolTranslationError("invalid_tool_call", "OpenAI tool_call is missing id/function fields.");
|
|
311
|
+
}
|
|
312
|
+
if (toolCall.type !== "function") {
|
|
313
|
+
throw new ProtocolTranslationError("unsupported_tool_call", "Only OpenAI function tool calls are supported.");
|
|
314
|
+
}
|
|
315
|
+
if (typeof toolCall.function.name !== "string") {
|
|
316
|
+
throw new ProtocolTranslationError("invalid_tool_call", "OpenAI tool_call.function.name must be a string.");
|
|
317
|
+
}
|
|
318
|
+
if (typeof toolCall.function.arguments !== "string") {
|
|
319
|
+
throw new ProtocolTranslationError("invalid_tool_call", "OpenAI tool_call.function.arguments must be a string.");
|
|
320
|
+
}
|
|
321
|
+
blocks.push({
|
|
322
|
+
type: "tool_use",
|
|
323
|
+
id: toolCall.id,
|
|
324
|
+
name: toolCall.function.name,
|
|
325
|
+
input: parseToolArguments(toolCall.function.arguments)
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return blocks.length > 0 ? blocks : [{ type: "text", text: "" }];
|
|
330
|
+
}
|
|
331
|
+
function translateOpenAIContentToAnthropicBlocks(content) {
|
|
332
|
+
if (typeof content === "string") {
|
|
333
|
+
return content.length > 0 ? [{ type: "text", text: content }] : [];
|
|
334
|
+
}
|
|
335
|
+
if (content == null) {
|
|
336
|
+
return [];
|
|
337
|
+
}
|
|
338
|
+
if (!Array.isArray(content)) {
|
|
339
|
+
throw new ProtocolTranslationError("unsupported_content_shape", "OpenAI message content must be a string, null, or an array of content parts.");
|
|
340
|
+
}
|
|
341
|
+
const blocks = [];
|
|
342
|
+
for (const part of content) {
|
|
343
|
+
if (!isObject(part) || part.type !== "text" || typeof part.text !== "string") {
|
|
344
|
+
throw new ProtocolTranslationError("unsupported_content_part", "Only OpenAI text content parts are supported in Anthropic response translation.");
|
|
345
|
+
}
|
|
346
|
+
blocks.push({ type: "text", text: part.text });
|
|
347
|
+
}
|
|
348
|
+
return blocks;
|
|
349
|
+
}
|
|
350
|
+
function parseToolArguments(argumentsRaw) {
|
|
351
|
+
try {
|
|
352
|
+
return JSON.parse(argumentsRaw);
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
throw new ProtocolTranslationError("invalid_tool_arguments", "OpenAI tool call arguments must be valid JSON.");
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function mapFinishReason(reason) {
|
|
359
|
+
if (reason == null) {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
switch (reason) {
|
|
363
|
+
case "stop":
|
|
364
|
+
return "end_turn";
|
|
365
|
+
case "length":
|
|
366
|
+
return "max_tokens";
|
|
367
|
+
case "tool_calls":
|
|
368
|
+
case "function_call":
|
|
369
|
+
return "tool_use";
|
|
370
|
+
case "content_filter":
|
|
371
|
+
return "refusal";
|
|
372
|
+
default:
|
|
373
|
+
throw new ProtocolTranslationError("unsupported_finish_reason", `Unsupported OpenAI finish_reason: ${reason}.`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function deriveAnthropicMessageId(openAIId, payload) {
|
|
377
|
+
const base = typeof openAIId === "string" && openAIId.length > 0
|
|
378
|
+
? openAIId
|
|
379
|
+
: stableHash(JSON.stringify({ model: payload.model ?? "", choices: payload.choices ?? [] }));
|
|
380
|
+
const normalized = base.replace(/[^a-zA-Z0-9_-]/g, "");
|
|
381
|
+
if (normalized.startsWith("msg_")) {
|
|
382
|
+
return normalized;
|
|
383
|
+
}
|
|
384
|
+
return `msg_${normalized || stableHash(base)}`;
|
|
385
|
+
}
|
|
386
|
+
function stableHash(value) {
|
|
387
|
+
return createHash("sha1").update(value).digest("hex").slice(0, 24);
|
|
388
|
+
}
|
|
389
|
+
function isObject(value) {
|
|
390
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
391
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
const PING_INTERVAL_MS = 1000;
|
|
3
|
+
export async function translateOpenAIStreamToAnthropic(options) {
|
|
4
|
+
const { upstream, downstream } = options;
|
|
5
|
+
upstream.setEncoding("utf8");
|
|
6
|
+
let buffer = "";
|
|
7
|
+
let messageStarted = options.preEmittedMessageId !== undefined;
|
|
8
|
+
let messageId = options.preEmittedMessageId ?? null;
|
|
9
|
+
let modelName = options.fallbackModel ?? "";
|
|
10
|
+
let role = "assistant";
|
|
11
|
+
let inputTokens = 0;
|
|
12
|
+
let outputTokens = 0;
|
|
13
|
+
let cacheReadTokens = 0;
|
|
14
|
+
let stopReason = null;
|
|
15
|
+
const activeBlocks = new Map();
|
|
16
|
+
let nextAnthropicIndex = 0;
|
|
17
|
+
let streamErrored = false;
|
|
18
|
+
let pingTimer = null;
|
|
19
|
+
function startPings() {
|
|
20
|
+
if (pingTimer !== null) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
pingTimer = setInterval(() => {
|
|
24
|
+
writeEvent("ping", { type: "ping" });
|
|
25
|
+
}, PING_INTERVAL_MS);
|
|
26
|
+
if (typeof pingTimer.unref === "function") {
|
|
27
|
+
pingTimer.unref();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function stopPings() {
|
|
31
|
+
if (pingTimer !== null) {
|
|
32
|
+
clearInterval(pingTimer);
|
|
33
|
+
pingTimer = null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function writeEvent(eventName, data) {
|
|
37
|
+
downstream.write(`event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
38
|
+
}
|
|
39
|
+
function emitMessageStart() {
|
|
40
|
+
if (messageStarted) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
messageStarted = true;
|
|
44
|
+
if (!messageId) {
|
|
45
|
+
messageId = `msg_${randomUUID().replace(/-/g, "").slice(0, 24)}`;
|
|
46
|
+
}
|
|
47
|
+
writeEvent("message_start", {
|
|
48
|
+
type: "message_start",
|
|
49
|
+
message: {
|
|
50
|
+
id: messageId,
|
|
51
|
+
type: "message",
|
|
52
|
+
role,
|
|
53
|
+
content: [],
|
|
54
|
+
model: modelName,
|
|
55
|
+
stop_reason: null,
|
|
56
|
+
stop_sequence: null,
|
|
57
|
+
usage: {
|
|
58
|
+
input_tokens: inputTokens,
|
|
59
|
+
cache_read_input_tokens: cacheReadTokens,
|
|
60
|
+
output_tokens: 0
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function ensureTextBlock() {
|
|
66
|
+
const existing = activeBlocks.get("text");
|
|
67
|
+
if (existing) {
|
|
68
|
+
return existing.anthropicIndex;
|
|
69
|
+
}
|
|
70
|
+
const index = nextAnthropicIndex;
|
|
71
|
+
nextAnthropicIndex += 1;
|
|
72
|
+
writeEvent("content_block_start", {
|
|
73
|
+
type: "content_block_start",
|
|
74
|
+
index,
|
|
75
|
+
content_block: { type: "text", text: "" }
|
|
76
|
+
});
|
|
77
|
+
activeBlocks.set("text", { anthropicIndex: index, kind: "text" });
|
|
78
|
+
return index;
|
|
79
|
+
}
|
|
80
|
+
function ensureToolBlock(toolIndex, id, name) {
|
|
81
|
+
const key = `tool:${toolIndex}`;
|
|
82
|
+
const existing = activeBlocks.get(key);
|
|
83
|
+
if (existing) {
|
|
84
|
+
return existing.anthropicIndex;
|
|
85
|
+
}
|
|
86
|
+
const textBlock = activeBlocks.get("text");
|
|
87
|
+
if (textBlock) {
|
|
88
|
+
writeEvent("content_block_stop", { type: "content_block_stop", index: textBlock.anthropicIndex });
|
|
89
|
+
activeBlocks.delete("text");
|
|
90
|
+
}
|
|
91
|
+
const index = nextAnthropicIndex;
|
|
92
|
+
nextAnthropicIndex += 1;
|
|
93
|
+
writeEvent("content_block_start", {
|
|
94
|
+
type: "content_block_start",
|
|
95
|
+
index,
|
|
96
|
+
content_block: { type: "tool_use", id, name, input: {} }
|
|
97
|
+
});
|
|
98
|
+
activeBlocks.set(key, { anthropicIndex: index, kind: "tool_use" });
|
|
99
|
+
return index;
|
|
100
|
+
}
|
|
101
|
+
function closeAllBlocks() {
|
|
102
|
+
const ordered = Array.from(activeBlocks.values()).sort((a, b) => a.anthropicIndex - b.anthropicIndex);
|
|
103
|
+
for (const block of ordered) {
|
|
104
|
+
writeEvent("content_block_stop", { type: "content_block_stop", index: block.anthropicIndex });
|
|
105
|
+
}
|
|
106
|
+
activeBlocks.clear();
|
|
107
|
+
}
|
|
108
|
+
function processChunk(parsed) {
|
|
109
|
+
if (parsed.id && !messageId) {
|
|
110
|
+
messageId = `msg_${parsed.id}`;
|
|
111
|
+
}
|
|
112
|
+
if (parsed.model && !modelName) {
|
|
113
|
+
modelName = parsed.model;
|
|
114
|
+
}
|
|
115
|
+
if (parsed.usage) {
|
|
116
|
+
if (typeof parsed.usage.prompt_tokens === "number") {
|
|
117
|
+
inputTokens = parsed.usage.prompt_tokens;
|
|
118
|
+
}
|
|
119
|
+
if (typeof parsed.usage.completion_tokens === "number") {
|
|
120
|
+
outputTokens = parsed.usage.completion_tokens;
|
|
121
|
+
}
|
|
122
|
+
const cached = parsed.usage.prompt_tokens_details?.cached_tokens;
|
|
123
|
+
if (typeof cached === "number") {
|
|
124
|
+
cacheReadTokens = cached;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const choice = parsed.choices?.[0];
|
|
128
|
+
if (!choice) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (choice.delta?.role && typeof choice.delta.role === "string") {
|
|
132
|
+
role = choice.delta.role;
|
|
133
|
+
}
|
|
134
|
+
emitMessageStart();
|
|
135
|
+
const delta = choice.delta;
|
|
136
|
+
if (delta && typeof delta.content === "string" && delta.content.length > 0) {
|
|
137
|
+
const index = ensureTextBlock();
|
|
138
|
+
writeEvent("content_block_delta", {
|
|
139
|
+
type: "content_block_delta",
|
|
140
|
+
index,
|
|
141
|
+
delta: { type: "text_delta", text: delta.content }
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (delta && Array.isArray(delta.tool_calls)) {
|
|
145
|
+
for (const toolCall of delta.tool_calls) {
|
|
146
|
+
const toolIndex = typeof toolCall.index === "number" ? toolCall.index : 0;
|
|
147
|
+
const key = `tool:${toolIndex}`;
|
|
148
|
+
let blockIndex = null;
|
|
149
|
+
if (!activeBlocks.has(key)) {
|
|
150
|
+
const id = typeof toolCall.id === "string" ? toolCall.id : "";
|
|
151
|
+
const name = typeof toolCall.function?.name === "string" ? toolCall.function.name : "";
|
|
152
|
+
if (id.length === 0 || name.length === 0) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
blockIndex = ensureToolBlock(toolIndex, id, name);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
blockIndex = activeBlocks.get(key).anthropicIndex;
|
|
159
|
+
}
|
|
160
|
+
const args = toolCall.function?.arguments;
|
|
161
|
+
if (typeof args === "string" && args.length > 0) {
|
|
162
|
+
writeEvent("content_block_delta", {
|
|
163
|
+
type: "content_block_delta",
|
|
164
|
+
index: blockIndex,
|
|
165
|
+
delta: { type: "input_json_delta", partial_json: args }
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (typeof choice.finish_reason === "string" && choice.finish_reason.length > 0) {
|
|
171
|
+
stopReason = mapFinishReason(choice.finish_reason);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function processLine(line) {
|
|
175
|
+
if (line.length === 0) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (!line.startsWith("data:")) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const payload = line.slice(5).trim();
|
|
182
|
+
if (payload.length === 0) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (payload === "[DONE]") {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
let parsed;
|
|
189
|
+
try {
|
|
190
|
+
parsed = JSON.parse(payload);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
processChunk(parsed);
|
|
196
|
+
}
|
|
197
|
+
emitMessageStart();
|
|
198
|
+
startPings();
|
|
199
|
+
try {
|
|
200
|
+
for await (const chunk of upstream) {
|
|
201
|
+
const text = typeof chunk === "string" ? chunk : Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
|
202
|
+
buffer += text;
|
|
203
|
+
let newlineIndex;
|
|
204
|
+
while ((newlineIndex = buffer.indexOf("\n")) >= 0) {
|
|
205
|
+
const rawLine = buffer.slice(0, newlineIndex).replace(/\r$/, "");
|
|
206
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
207
|
+
processLine(rawLine);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (buffer.length > 0) {
|
|
211
|
+
processLine(buffer.replace(/\r$/, ""));
|
|
212
|
+
buffer = "";
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
streamErrored = true;
|
|
217
|
+
stopPings();
|
|
218
|
+
emitMessageStart();
|
|
219
|
+
closeAllBlocks();
|
|
220
|
+
writeEvent("message_delta", {
|
|
221
|
+
type: "message_delta",
|
|
222
|
+
delta: { stop_reason: "end_turn", stop_sequence: null },
|
|
223
|
+
usage: { input_tokens: inputTokens, output_tokens: outputTokens, cache_read_input_tokens: cacheReadTokens }
|
|
224
|
+
});
|
|
225
|
+
writeEvent("error", {
|
|
226
|
+
type: "error",
|
|
227
|
+
error: { type: "api_error", message: error instanceof Error ? error.message : "upstream stream error" }
|
|
228
|
+
});
|
|
229
|
+
writeEvent("message_stop", { type: "message_stop" });
|
|
230
|
+
downstream.end();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (streamErrored) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
stopPings();
|
|
237
|
+
emitMessageStart();
|
|
238
|
+
closeAllBlocks();
|
|
239
|
+
writeEvent("message_delta", {
|
|
240
|
+
type: "message_delta",
|
|
241
|
+
delta: { stop_reason: stopReason ?? "end_turn", stop_sequence: null },
|
|
242
|
+
usage: { input_tokens: inputTokens, output_tokens: outputTokens, cache_read_input_tokens: cacheReadTokens }
|
|
243
|
+
});
|
|
244
|
+
writeEvent("message_stop", { type: "message_stop" });
|
|
245
|
+
downstream.end();
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Write the Anthropic `message_start` event (and an initial ping) to the
|
|
249
|
+
* downstream SSE response immediately, before we begin awaiting the upstream
|
|
250
|
+
* Copilot response. This makes Claude Code render the assistant turn frame
|
|
251
|
+
* within milliseconds of the request, instead of waiting the full ~2s of
|
|
252
|
+
* upstream first-token latency before seeing any sign of life.
|
|
253
|
+
*
|
|
254
|
+
* The returned `messageId` MUST be passed to translateOpenAIStreamToAnthropic
|
|
255
|
+
* via `preEmittedMessageId` so it doesn't double-emit `message_start`.
|
|
256
|
+
*/
|
|
257
|
+
export function writeAnthropicPrelude(downstream, model) {
|
|
258
|
+
const messageId = `msg_${randomUUID().replace(/-/g, "").slice(0, 24)}`;
|
|
259
|
+
const messageStart = {
|
|
260
|
+
type: "message_start",
|
|
261
|
+
message: {
|
|
262
|
+
id: messageId,
|
|
263
|
+
type: "message",
|
|
264
|
+
role: "assistant",
|
|
265
|
+
content: [],
|
|
266
|
+
model,
|
|
267
|
+
stop_reason: null,
|
|
268
|
+
stop_sequence: null,
|
|
269
|
+
usage: { input_tokens: 0, cache_read_input_tokens: 0, output_tokens: 0 }
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
downstream.write(`event: message_start\ndata: ${JSON.stringify(messageStart)}\n\n`);
|
|
273
|
+
downstream.write(`event: ping\ndata: ${JSON.stringify({ type: "ping" })}\n\n`);
|
|
274
|
+
return { messageId };
|
|
275
|
+
}
|
|
276
|
+
function mapFinishReason(reason) {
|
|
277
|
+
switch (reason) {
|
|
278
|
+
case "stop":
|
|
279
|
+
return "end_turn";
|
|
280
|
+
case "length":
|
|
281
|
+
return "max_tokens";
|
|
282
|
+
case "tool_calls":
|
|
283
|
+
case "function_call":
|
|
284
|
+
return "tool_use";
|
|
285
|
+
case "content_filter":
|
|
286
|
+
return "end_turn";
|
|
287
|
+
default:
|
|
288
|
+
return "end_turn";
|
|
289
|
+
}
|
|
290
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|