503 lines
15 KiB
TypeScript
503 lines
15 KiB
TypeScript
/* Anthropic Messages API <-> OpenAI Chat Completions */
|
|
|
|
import type { ChatCompletionRequest, ChatMessage } from "./responses";
|
|
|
|
export interface AnthropicMessageRequest {
|
|
model: string;
|
|
max_tokens: number;
|
|
messages: AnthropicMessage[];
|
|
system?: string | AnthropicContentBlock[];
|
|
stream?: boolean;
|
|
temperature?: number;
|
|
top_p?: number;
|
|
tools?: AnthropicTool[];
|
|
tool_choice?: { type: string; name?: string } | string;
|
|
}
|
|
|
|
interface AnthropicMessage {
|
|
role: "user" | "assistant";
|
|
content: string | AnthropicContentBlock[];
|
|
}
|
|
|
|
interface AnthropicContentBlock {
|
|
type: string;
|
|
text?: string;
|
|
source?: { type: string; media_type?: string; data?: string; url?: string };
|
|
id?: string;
|
|
name?: string;
|
|
input?: unknown;
|
|
tool_use_id?: string;
|
|
content?: string | AnthropicContentBlock[];
|
|
}
|
|
|
|
interface AnthropicTool {
|
|
name: string;
|
|
description?: string;
|
|
input_schema?: unknown;
|
|
}
|
|
|
|
export interface OpenAIChatResponse {
|
|
id: string;
|
|
model: string;
|
|
choices: Array<{
|
|
message: {
|
|
role: string;
|
|
content: string | null;
|
|
tool_calls?: Array<{
|
|
id: string;
|
|
type: string;
|
|
function: { name: string; arguments: string };
|
|
}>;
|
|
};
|
|
finish_reason: string | null;
|
|
}>;
|
|
usage?: {
|
|
prompt_tokens: number;
|
|
completion_tokens: number;
|
|
total_tokens: number;
|
|
};
|
|
}
|
|
|
|
function extractSystem(system: AnthropicMessageRequest["system"]): string | undefined {
|
|
if (!system) return undefined;
|
|
if (typeof system === "string") return system;
|
|
return system
|
|
.filter((b) => b.type === "text" && b.text)
|
|
.map((b) => b.text!)
|
|
.join("");
|
|
}
|
|
|
|
function anthropicImageToOpenAI(block: AnthropicContentBlock): unknown | null {
|
|
if (block.type !== "image") return null;
|
|
const src = block.source;
|
|
if (!src) return null;
|
|
if (src.type === "base64" && src.media_type && src.data) {
|
|
return {
|
|
type: "image_url",
|
|
image_url: { url: `data:${src.media_type};base64,${src.data}` },
|
|
};
|
|
}
|
|
if (src.type === "url" && src.url) {
|
|
return { type: "image_url", image_url: { url: src.url } };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function anthropicContentToOpenAI(
|
|
content: string | AnthropicContentBlock[],
|
|
role: string,
|
|
): string | unknown[] {
|
|
if (typeof content === "string") return content;
|
|
|
|
const parts: unknown[] = [];
|
|
for (const block of content) {
|
|
if (block.type === "text" && block.text) {
|
|
parts.push({ type: "text", text: block.text });
|
|
} else if (block.type === "image") {
|
|
const img = anthropicImageToOpenAI(block);
|
|
if (img) parts.push(img);
|
|
}
|
|
}
|
|
return parts.length === 1 && parts[0] && (parts[0] as { type: string }).type === "text"
|
|
? (parts[0] as { text: string }).text
|
|
: parts;
|
|
}
|
|
|
|
function anthropicToolsToOpenAI(tools?: AnthropicTool[]): ChatCompletionRequest["tools"] {
|
|
if (!tools?.length) return undefined;
|
|
return tools.map((t) => ({
|
|
type: "function",
|
|
function: {
|
|
name: t.name,
|
|
description: t.description ?? "",
|
|
parameters: t.input_schema ?? { type: "object", properties: {} },
|
|
},
|
|
}));
|
|
}
|
|
|
|
function anthropicToolChoiceToOpenAI(
|
|
toolChoice?: AnthropicMessageRequest["tool_choice"],
|
|
): unknown {
|
|
if (!toolChoice) return undefined;
|
|
if (typeof toolChoice === "string") return toolChoice;
|
|
if (toolChoice.type === "tool" && toolChoice.name) {
|
|
return { type: "function", function: { name: toolChoice.name } };
|
|
}
|
|
if (toolChoice.type === "any") return "required";
|
|
return toolChoice.type === "auto" ? "auto" : toolChoice;
|
|
}
|
|
|
|
export function anthropicToOpenAI(req: AnthropicMessageRequest): ChatCompletionRequest {
|
|
let model = req.model;
|
|
if (model.startsWith("github_copilot/")) {
|
|
model = model.slice("github_copilot/".length);
|
|
}
|
|
|
|
const messages: ChatMessage[] = [];
|
|
const systemText = extractSystem(req.system);
|
|
if (systemText) {
|
|
messages.push({ role: "system", content: systemText });
|
|
}
|
|
|
|
for (const msg of req.messages) {
|
|
if (typeof msg.content === "string") {
|
|
messages.push({ role: msg.role, content: msg.content });
|
|
continue;
|
|
}
|
|
|
|
const toolResults = msg.content.filter((b) => b.type === "tool_result");
|
|
const toolUses = msg.content.filter((b) => b.type === "tool_use");
|
|
const other = msg.content.filter((b) => b.type !== "tool_result" && b.type !== "tool_use");
|
|
|
|
if (msg.role === "assistant" && toolUses.length > 0) {
|
|
const text = other
|
|
.filter((b) => b.type === "text" && b.text)
|
|
.map((b) => b.text)
|
|
.join("");
|
|
messages.push({
|
|
role: "assistant",
|
|
content: text || null,
|
|
tool_calls: toolUses.map((tu) => ({
|
|
id: tu.id ?? `toolu_${crypto.randomUUID().slice(0, 12)}`,
|
|
type: "function",
|
|
function: {
|
|
name: tu.name ?? "",
|
|
arguments: JSON.stringify(tu.input ?? {}),
|
|
},
|
|
})),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (msg.role === "user" && toolResults.length > 0) {
|
|
if (other.length > 0) {
|
|
messages.push({
|
|
role: "user",
|
|
content: anthropicContentToOpenAI(other, "user"),
|
|
});
|
|
}
|
|
for (const tr of toolResults) {
|
|
const resultContent =
|
|
typeof tr.content === "string"
|
|
? tr.content
|
|
: Array.isArray(tr.content)
|
|
? tr.content
|
|
.filter((b) => b.type === "text" && b.text)
|
|
.map((b) => b.text)
|
|
.join("")
|
|
: JSON.stringify(tr.content ?? "");
|
|
messages.push({
|
|
role: "tool",
|
|
tool_call_id: tr.tool_use_id ?? "",
|
|
content: resultContent,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
messages.push({
|
|
role: msg.role,
|
|
content: anthropicContentToOpenAI(msg.content, msg.role),
|
|
});
|
|
}
|
|
|
|
return {
|
|
model,
|
|
messages,
|
|
max_tokens: req.max_tokens,
|
|
stream: req.stream ?? false,
|
|
temperature: req.temperature,
|
|
top_p: req.top_p,
|
|
tools: anthropicToolsToOpenAI(req.tools),
|
|
tool_choice: anthropicToolChoiceToOpenAI(req.tool_choice),
|
|
};
|
|
}
|
|
|
|
export function openAIToAnthropic(
|
|
openai: OpenAIChatResponse,
|
|
model: string,
|
|
): Record<string, unknown> {
|
|
const message = openai.choices[0]?.message;
|
|
const usage = openai.usage;
|
|
const content: AnthropicContentBlock[] = [];
|
|
|
|
if (message?.content) {
|
|
content.push({ type: "text", text: message.content });
|
|
}
|
|
|
|
if (message?.tool_calls?.length) {
|
|
for (const tc of message.tool_calls) {
|
|
let input: unknown = {};
|
|
try {
|
|
input = JSON.parse(tc.function.arguments || "{}");
|
|
} catch {
|
|
input = { raw: tc.function.arguments };
|
|
}
|
|
content.push({
|
|
type: "tool_use",
|
|
id: tc.id,
|
|
name: tc.function.name,
|
|
input,
|
|
});
|
|
}
|
|
}
|
|
|
|
const finishReason = openai.choices[0]?.finish_reason;
|
|
const stopReason =
|
|
finishReason === "tool_calls"
|
|
? "tool_use"
|
|
: finishReason === "length"
|
|
? "max_tokens"
|
|
: "end_turn";
|
|
|
|
return {
|
|
id: `msg_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`,
|
|
type: "message",
|
|
role: "assistant",
|
|
model,
|
|
content: content.length > 0 ? content : [{ type: "text", text: "" }],
|
|
stop_reason: stopReason,
|
|
stop_sequence: null,
|
|
usage: {
|
|
input_tokens: usage?.prompt_tokens ?? 0,
|
|
output_tokens: usage?.completion_tokens ?? 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
function mapStopReason(reason: string | null | undefined): string {
|
|
if (reason === "length") return "max_tokens";
|
|
if (reason === "tool_calls") return "tool_use";
|
|
if (reason === "stop") return "end_turn";
|
|
return "end_turn";
|
|
}
|
|
|
|
function anthropicError(message: string, type = "api_error"): Response {
|
|
return new Response(
|
|
JSON.stringify({
|
|
type: "error",
|
|
error: { type, message },
|
|
}),
|
|
{ status: 502, headers: { "content-type": "application/json" } },
|
|
);
|
|
}
|
|
|
|
function sseEvent(event: string, data: unknown): string {
|
|
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
}
|
|
|
|
export function transformOpenAIStreamToAnthropic(
|
|
openaiBody: ReadableStream<Uint8Array>,
|
|
model: string,
|
|
): ReadableStream<Uint8Array> {
|
|
const encoder = new TextEncoder();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
const messageId = `msg_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`;
|
|
let blockIndex = 0;
|
|
let started = false;
|
|
let outputTokens = 0;
|
|
let toolBlocksStarted = 0;
|
|
|
|
const startMessage = (controller: ReadableStreamDefaultController<Uint8Array>) => {
|
|
if (started) return;
|
|
started = true;
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
sseEvent("message_start", {
|
|
type: "message_start",
|
|
message: {
|
|
id: messageId,
|
|
type: "message",
|
|
role: "assistant",
|
|
model,
|
|
content: [],
|
|
stop_reason: null,
|
|
stop_sequence: null,
|
|
usage: { input_tokens: 0, output_tokens: 0 },
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
};
|
|
|
|
const startTextBlock = (controller: ReadableStreamDefaultController<Uint8Array>) => {
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
sseEvent("content_block_start", {
|
|
type: "content_block_start",
|
|
index: blockIndex,
|
|
content_block: { type: "text", text: "" },
|
|
}),
|
|
),
|
|
);
|
|
};
|
|
|
|
return new ReadableStream({
|
|
async start(controller) {
|
|
const reader = openaiBody.getReader();
|
|
const pendingTools = new Map<number, { id: string; name: string; args: string }>();
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop() ?? "";
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith("data: ")) continue;
|
|
const payload = trimmed.slice(6);
|
|
if (payload === "[DONE]") continue;
|
|
|
|
let chunk: {
|
|
choices?: Array<{
|
|
delta?: {
|
|
content?: string;
|
|
role?: string;
|
|
tool_calls?: Array<{
|
|
index?: number;
|
|
id?: string;
|
|
function?: { name?: string; arguments?: string };
|
|
}>;
|
|
};
|
|
finish_reason?: string | null;
|
|
}>;
|
|
usage?: { completion_tokens?: number };
|
|
};
|
|
try {
|
|
chunk = JSON.parse(payload);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
if (chunk.usage?.completion_tokens) {
|
|
outputTokens = chunk.usage.completion_tokens;
|
|
}
|
|
|
|
const delta = chunk.choices?.[0]?.delta;
|
|
const finishReason = chunk.choices?.[0]?.finish_reason;
|
|
|
|
if (delta?.content || delta?.tool_calls || finishReason) {
|
|
startMessage(controller);
|
|
}
|
|
|
|
if (delta?.content) {
|
|
if (blockIndex === 0 && toolBlocksStarted === 0) {
|
|
startTextBlock(controller);
|
|
}
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
sseEvent("content_block_delta", {
|
|
type: "content_block_delta",
|
|
index: blockIndex,
|
|
delta: { type: "text_delta", text: delta.content },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (delta?.tool_calls) {
|
|
for (const tc of delta.tool_calls) {
|
|
const idx = tc.index ?? 0;
|
|
if (!pendingTools.has(idx)) {
|
|
pendingTools.set(idx, {
|
|
id: tc.id ?? `toolu_${idx}`,
|
|
name: tc.function?.name ?? "",
|
|
args: "",
|
|
});
|
|
const bi = blockIndex + 1 + toolBlocksStarted;
|
|
toolBlocksStarted++;
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
sseEvent("content_block_start", {
|
|
type: "content_block_start",
|
|
index: bi,
|
|
content_block: {
|
|
type: "tool_use",
|
|
id: pendingTools.get(idx)!.id,
|
|
name: pendingTools.get(idx)!.name,
|
|
input: {},
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
const tool = pendingTools.get(idx)!;
|
|
if (tc.id) tool.id = tc.id;
|
|
if (tc.function?.name) tool.name = tc.function.name;
|
|
if (tc.function?.arguments) {
|
|
tool.args += tc.function.arguments;
|
|
let partial: unknown = {};
|
|
try {
|
|
partial = JSON.parse(tool.args);
|
|
} catch {
|
|
partial = { partial: tool.args };
|
|
}
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
sseEvent("content_block_delta", {
|
|
type: "content_block_delta",
|
|
index: blockIndex + toolBlocksStarted,
|
|
delta: { type: "input_json_delta", partial_json: tc.function.arguments },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (finishReason) {
|
|
if (blockIndex === 0 && !delta?.tool_calls && toolBlocksStarted === 0) {
|
|
startTextBlock(controller);
|
|
}
|
|
if (blockIndex === 0) {
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
sseEvent("content_block_stop", {
|
|
type: "content_block_stop",
|
|
index: blockIndex,
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
for (let i = 0; i < toolBlocksStarted; i++) {
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
sseEvent("content_block_stop", {
|
|
type: "content_block_stop",
|
|
index: blockIndex + 1 + i,
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
sseEvent("message_delta", {
|
|
type: "message_delta",
|
|
delta: {
|
|
stop_reason: mapStopReason(finishReason),
|
|
stop_sequence: null,
|
|
},
|
|
usage: { output_tokens: outputTokens || 1 },
|
|
}),
|
|
),
|
|
);
|
|
controller.enqueue(
|
|
encoder.encode(sseEvent("message_stop", { type: "message_stop" })),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
controller.close();
|
|
} catch (e) {
|
|
controller.error(e);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
export { anthropicError };
|