Skip to content

Commit d3d0796

Browse files
committed
feat: Add Anthropic API translation and streaming support
1 parent 6e0f213 commit d3d0796

File tree

6 files changed

+759
-1
lines changed

6 files changed

+759
-1
lines changed
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Anthropic API Types
2+
3+
export interface AnthropicMessagesPayload {
4+
model: string
5+
messages: Array<AnthropicMessage>
6+
max_tokens: number
7+
system?: string | Array<AnthropicTextBlock>
8+
metadata?: {
9+
user_id?: string
10+
}
11+
stop_sequences?: Array<string>
12+
stream?: boolean
13+
temperature?: number
14+
top_p?: number
15+
top_k?: number
16+
tools?: Array<AnthropicTool>
17+
tool_choice?: {
18+
type: "auto" | "any" | "tool" | "none"
19+
name?: string
20+
}
21+
}
22+
23+
export interface AnthropicMessage {
24+
role: "user" | "assistant"
25+
content: string | Array<AnthropicContentBlock>
26+
}
27+
28+
export type AnthropicContentBlock =
29+
| AnthropicTextBlock
30+
| AnthropicImageBlock
31+
| AnthropicToolResultBlock
32+
33+
export interface AnthropicTextBlock {
34+
type: "text"
35+
text: string
36+
}
37+
38+
export interface AnthropicImageBlock {
39+
type: "image"
40+
source: {
41+
type: "base64"
42+
media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp"
43+
data: string
44+
}
45+
}
46+
47+
export interface AnthropicToolResultBlock {
48+
type: "tool_result"
49+
tool_use_id: string
50+
content: string
51+
is_error?: boolean
52+
}
53+
54+
export interface AnthropicTool {
55+
name: string
56+
description?: string
57+
input_schema: Record<string, unknown>
58+
}
59+
60+
export interface AnthropicResponse {
61+
id: string
62+
type: "message"
63+
role: "assistant"
64+
content: Array<AnthropicResponseContentBlock>
65+
model: string
66+
stop_reason: "end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | null
67+
stop_sequence: string | null
68+
usage: {
69+
input_tokens: number
70+
output_tokens: number
71+
}
72+
}
73+
74+
export type AnthropicResponseContentBlock =
75+
| AnthropicTextBlock
76+
| AnthropicToolUseBlock
77+
78+
export interface AnthropicToolUseBlock {
79+
type: "tool_use"
80+
id: string
81+
name: string
82+
input: Record<string, unknown>
83+
}
84+
85+
// Anthropic Stream Event Types
86+
export interface AnthropicMessageStartEvent {
87+
type: "message_start"
88+
message: Omit<
89+
AnthropicResponse,
90+
"stop_reason" | "stop_sequence" | "content"
91+
> & {
92+
content: []
93+
}
94+
}
95+
96+
export interface AnthropicContentBlockStartEvent {
97+
type: "content_block_start"
98+
index: number
99+
content_block:
100+
| { type: "text"; text: string }
101+
| (Omit<AnthropicToolUseBlock, "input"> & {
102+
input: Record<string, unknown>
103+
})
104+
}
105+
106+
export interface AnthropicContentBlockDeltaEvent {
107+
type: "content_block_delta"
108+
index: number
109+
delta:
110+
| { type: "text_delta"; text: string }
111+
| { type: "input_json_delta"; partial_json: string }
112+
}
113+
114+
export interface AnthropicContentBlockStopEvent {
115+
type: "content_block_stop"
116+
index: number
117+
}
118+
119+
export interface AnthropicMessageDeltaEvent {
120+
type: "message_delta"
121+
delta: {
122+
stop_reason: AnthropicResponse["stop_reason"]
123+
stop_sequence: string | null
124+
}
125+
// OpenAI does not provide token usage per chunk, so this is omitted.
126+
// usage: { output_tokens: number }
127+
}
128+
129+
export interface AnthropicMessageStopEvent {
130+
type: "message_stop"
131+
}
132+
133+
export type AnthropicStreamEventData =
134+
| AnthropicMessageStartEvent
135+
| AnthropicContentBlockStartEvent
136+
| AnthropicContentBlockDeltaEvent
137+
| AnthropicContentBlockStopEvent
138+
| AnthropicMessageDeltaEvent
139+
| AnthropicMessageStopEvent
140+
141+
// State for streaming translation
142+
export interface AnthropicStreamState {
143+
messageStartSent: boolean
144+
contentBlockIndex: number
145+
contentBlockOpen: boolean
146+
toolCalls: {
147+
[openAIToolIndex: number]: {
148+
id: string
149+
name: string
150+
anthropicBlockIndex: number
151+
}
152+
}
153+
}
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import {
2+
type ChatCompletionResponse,
3+
type ChatCompletionsPayload,
4+
type ContentPart,
5+
type Message,
6+
type TextPart,
7+
type Tool,
8+
type ToolCall,
9+
} from "~/services/copilot/create-chat-completions"
10+
11+
import {
12+
type AnthropicContentBlock,
13+
type AnthropicMessage,
14+
type AnthropicMessagesPayload,
15+
type AnthropicResponse,
16+
type AnthropicTextBlock,
17+
type AnthropicTool,
18+
type AnthropicToolResultBlock,
19+
type AnthropicToolUseBlock,
20+
} from "./anthropic-types"
21+
import { mapOpenAIStopReasonToAnthropic } from "./utils"
22+
23+
// Payload translation
24+
25+
export function translateToOpenAI(
26+
payload: AnthropicMessagesPayload,
27+
): ChatCompletionsPayload {
28+
return {
29+
model: payload.model,
30+
messages: translateAnthropicMessagesToOpenAI(
31+
payload.messages,
32+
payload.system,
33+
),
34+
max_tokens: payload.max_tokens,
35+
stop: payload.stop_sequences,
36+
stream: payload.stream,
37+
temperature: payload.temperature,
38+
top_p: payload.top_p,
39+
user: payload.metadata?.user_id,
40+
tools: translateAnthropicToolsToOpenAI(payload.tools),
41+
tool_choice: translateAnthropicToolChoiceToOpenAI(payload.tool_choice),
42+
}
43+
}
44+
45+
function translateAnthropicMessagesToOpenAI(
46+
anthropicMessages: Array<AnthropicMessage>,
47+
system: string | Array<AnthropicTextBlock> | undefined,
48+
): Array<Message> {
49+
const messages: Array<Message> = []
50+
51+
if (system) {
52+
if (typeof system === "string") {
53+
messages.push({ role: "system", content: system })
54+
} else {
55+
const systemText = system.map((block) => block.text).join("\n\n")
56+
messages.push({ role: "system", content: systemText })
57+
}
58+
}
59+
60+
for (const message of anthropicMessages) {
61+
if (message.role === "user" && Array.isArray(message.content)) {
62+
const toolResultBlocks = message.content.filter(
63+
(block): block is AnthropicToolResultBlock =>
64+
block.type === "tool_result",
65+
)
66+
const otherBlocks = message.content.filter(
67+
(block) => block.type !== "tool_result",
68+
)
69+
70+
if (otherBlocks.length > 0) {
71+
messages.push({
72+
role: "user",
73+
content: mapContent(otherBlocks),
74+
})
75+
}
76+
77+
for (const block of toolResultBlocks) {
78+
messages.push({
79+
role: "tool",
80+
tool_call_id: block.tool_use_id,
81+
content: block.content,
82+
})
83+
}
84+
} else {
85+
messages.push({
86+
role: message.role,
87+
content: mapContent(message.content),
88+
})
89+
}
90+
}
91+
return messages
92+
}
93+
94+
function mapContent(
95+
content: string | Array<AnthropicContentBlock>,
96+
): string | Array<ContentPart> | null {
97+
if (typeof content === "string") {
98+
return content
99+
}
100+
if (!Array.isArray(content)) {
101+
return null
102+
}
103+
104+
const contentParts: Array<ContentPart> = []
105+
for (const block of content) {
106+
if (block.type === "text") {
107+
contentParts.push({ type: "text", text: block.text })
108+
} else if (block.type === "image") {
109+
contentParts.push({
110+
type: "image_url",
111+
image_url: {
112+
url: `data:${block.source.media_type};base64,${block.source.data}`,
113+
},
114+
})
115+
}
116+
}
117+
return contentParts
118+
}
119+
120+
function translateAnthropicToolsToOpenAI(
121+
anthropicTools: Array<AnthropicTool> | undefined,
122+
): Array<Tool> | undefined {
123+
if (!anthropicTools) {
124+
return undefined
125+
}
126+
return anthropicTools.map((tool) => ({
127+
type: "function",
128+
function: {
129+
name: tool.name,
130+
description: tool.description,
131+
parameters: tool.input_schema,
132+
},
133+
}))
134+
}
135+
136+
function translateAnthropicToolChoiceToOpenAI(
137+
anthropicToolChoice: AnthropicMessagesPayload["tool_choice"],
138+
): ChatCompletionsPayload["tool_choice"] {
139+
if (!anthropicToolChoice) {
140+
return undefined
141+
}
142+
143+
switch (anthropicToolChoice.type) {
144+
case "auto": {
145+
return "auto"
146+
}
147+
case "any": {
148+
return "required"
149+
}
150+
case "tool": {
151+
if (anthropicToolChoice.name) {
152+
return {
153+
type: "function",
154+
function: { name: anthropicToolChoice.name },
155+
}
156+
}
157+
return undefined
158+
}
159+
default: {
160+
return undefined
161+
}
162+
}
163+
}
164+
165+
// Response translation
166+
167+
export function translateToAnthropic(
168+
response: ChatCompletionResponse,
169+
): AnthropicResponse {
170+
const choice = response.choices[0]
171+
const textBlocks = getAnthropicTextBlocks(choice.message.content)
172+
const toolUseBlocks = getAnthropicToolUseBlocks(choice.message.tool_calls)
173+
174+
return {
175+
id: response.id,
176+
type: "message",
177+
role: "assistant",
178+
model: response.model,
179+
content: [...textBlocks, ...toolUseBlocks],
180+
stop_reason: mapOpenAIStopReasonToAnthropic(choice.finish_reason),
181+
stop_sequence: null,
182+
usage: {
183+
input_tokens: response.usage?.prompt_tokens ?? 0,
184+
output_tokens: response.usage?.completion_tokens ?? 0,
185+
},
186+
}
187+
}
188+
189+
function getAnthropicTextBlocks(
190+
messageContent: Message["content"],
191+
): Array<AnthropicTextBlock> {
192+
if (typeof messageContent === "string") {
193+
return [{ type: "text", text: messageContent }]
194+
}
195+
196+
if (Array.isArray(messageContent)) {
197+
return messageContent
198+
.filter((part): part is TextPart => part.type === "text")
199+
.map((part) => ({ type: "text", text: part.text }))
200+
}
201+
202+
return []
203+
}
204+
205+
function getAnthropicToolUseBlocks(
206+
toolCalls: Array<ToolCall> | undefined,
207+
): Array<AnthropicToolUseBlock> {
208+
if (!toolCalls) {
209+
return []
210+
}
211+
return toolCalls.map((toolCall) => ({
212+
type: "tool_use",
213+
id: toolCall.id,
214+
name: toolCall.function.name,
215+
input: JSON.parse(toolCall.function.arguments) as Record<string, unknown>,
216+
}))
217+
}

0 commit comments

Comments
 (0)