Skip to content

Commit ed89f11

Browse files
committed
anthropic mapping on gateway
1 parent 010f7c4 commit ed89f11

File tree

5 files changed

+286
-3
lines changed

5 files changed

+286
-3
lines changed

worker/src/lib/ai-gateway/SimpleAIGateway.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import { gatewayForwarder } from "../../routers/gatewayRouter";
1111
import { AttemptBuilder } from "./AttemptBuilder";
1212
import { AttemptExecutor } from "./AttemptExecutor";
1313
import { Attempt, DisallowListEntry, EscrowInfo } from "./types";
14+
import { ant2oaiNonStream } from "../clients/llmmapper/router/ant2oai/nonStream";
15+
import { ant2oaiStreamResponse } from "../clients/llmmapper/router/ant2oai/stream";
16+
import { ModelProviderName } from "@helicone-package/cost/models/providers";
1417

1518
export interface AuthContext {
1619
orgId: string;
@@ -138,7 +141,19 @@ export class SimpleAIGateway {
138141
} else {
139142
// Success!
140143
this.requestWrapper.setSuccessfulAttempt(attempt);
141-
return result.data;
144+
145+
const mappedResponse = await this.mapResponse(
146+
attempt,
147+
result.data,
148+
this.requestWrapper.heliconeHeaders.gatewayConfig.bodyMapping
149+
);
150+
151+
if (isErr(mappedResponse)) {
152+
console.error("Failed to map response:", mappedResponse.error);
153+
return result.data;
154+
}
155+
156+
return mappedResponse.data;
142157
}
143158
}
144159

@@ -276,6 +291,66 @@ export class SimpleAIGateway {
276291
);
277292
}
278293

294+
private determineResponseFormat(
295+
provider: ModelProviderName,
296+
modelId: string,
297+
bodyMapping?: "OPENAI" | "NO_MAPPING"
298+
): "ANTHROPIC" | "OPENAI" { // TODO: make enum type when there's more map formats
299+
if (bodyMapping !== "OPENAI") {
300+
return "OPENAI";
301+
}
302+
303+
if (
304+
provider === "anthropic" ||
305+
(provider === "bedrock" && modelId.includes("claude-"))
306+
) {
307+
return "ANTHROPIC";
308+
}
309+
310+
return "OPENAI";
311+
}
312+
313+
private async mapResponse(
314+
attempt: Attempt,
315+
response: Response,
316+
bodyMapping?: "OPENAI" | "NO_MAPPING"
317+
): Promise<Result<Response, string>> {
318+
const mappingType = this.determineResponseFormat(
319+
attempt.endpoint.provider,
320+
attempt.endpoint.providerModelId,
321+
bodyMapping
322+
);
323+
324+
if (mappingType === "OPENAI") {
325+
return ok(response);
326+
}
327+
328+
// clone to preserve original for logging
329+
const clonedResponse = response.clone();
330+
331+
try {
332+
if (mappingType === "ANTHROPIC") {
333+
const contentType = clonedResponse.headers.get("content-type");
334+
const isStream = contentType?.includes("text/event-stream");
335+
336+
if (isStream) {
337+
const mappedResponse = ant2oaiStreamResponse(clonedResponse);
338+
return ok(mappedResponse);
339+
} else {
340+
const mappedResponse = await ant2oaiNonStream(clonedResponse);
341+
return ok(mappedResponse);
342+
}
343+
}
344+
345+
return ok(response);
346+
} catch (error) {
347+
console.error("Failed to map response:", error);
348+
return err(
349+
error instanceof Error ? error.message : "Failed to map response"
350+
);
351+
}
352+
}
353+
279354
private async createErrorResponse(
280355
errors: Array<{
281356
attempt: string;

worker/src/lib/clients/llmmapper/providers/anthropic/request/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,25 @@ export interface AntRequestBody {
1414
top_k?: number;
1515
stop_sequences?: string[];
1616
stream?: boolean;
17+
tools?: AnthropicTool[];
18+
tool_choice?: AnthropicToolChoice;
1719
}
1820

21+
export interface AnthropicTool {
22+
name: string;
23+
description: string;
24+
input_schema: {
25+
type: "object";
26+
properties: Record<string, any>;
27+
required?: string[];
28+
};
29+
}
30+
31+
export type AnthropicToolChoice =
32+
| { type: "auto" }
33+
| { type: "any" }
34+
| { type: "tool"; name: string };
35+
1936
export interface ContentBlock {
2037
type: "text" | "image";
2138
text?: string;

worker/src/lib/clients/llmmapper/providers/openai/request/toAnthropic.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AntRequestBody, ContentBlock } from "../../anthropic/request/types";
1+
import { AntRequestBody, ContentBlock, AnthropicTool, AnthropicToolChoice } from "../../anthropic/request/types";
22
import { OpenAIRequestBody } from "./types";
33

44
export function toAnthropic(openAIBody: OpenAIRequestBody): AntRequestBody {
@@ -30,8 +30,19 @@ export function toAnthropic(openAIBody: OpenAIRequestBody): AntRequestBody {
3030
antBody.metadata = { user_id: openAIBody.user };
3131
}
3232

33+
// Map tools from OpenAI format to Anthropic format
34+
if (openAIBody.tools) {
35+
antBody.tools = mapTools(openAIBody.tools);
36+
}
37+
38+
// Map tool_choice from OpenAI format to Anthropic format
39+
if (openAIBody.tool_choice) {
40+
antBody.tool_choice = mapToolChoice(openAIBody.tool_choice);
41+
}
42+
43+
// Legacy function_call/functions not supported
3344
if (openAIBody.function_call || openAIBody.functions) {
34-
throw new Error("Function calling and tools are not supported");
45+
throw new Error("Legacy function_call and functions are not supported. Use tools instead.");
3546
}
3647

3748
if (openAIBody.logit_bias) {
@@ -110,3 +121,56 @@ function mapMessages(
110121
return antMessage;
111122
});
112123
}
124+
125+
function mapTools(tools: OpenAIRequestBody["tools"]): AnthropicTool[] {
126+
if (!tools) return [];
127+
128+
return tools.map((tool) => {
129+
if (tool.type !== "function") {
130+
throw new Error(`Unsupported tool type: ${tool.type}`);
131+
}
132+
133+
const inputSchema = tool.function.parameters as any || {
134+
type: "object",
135+
properties: {},
136+
};
137+
138+
return {
139+
name: tool.function.name,
140+
description: tool.function.description || "",
141+
input_schema: {
142+
type: "object" as const,
143+
properties: inputSchema.properties || {},
144+
required: inputSchema.required,
145+
},
146+
};
147+
});
148+
}
149+
150+
function mapToolChoice(toolChoice: OpenAIRequestBody["tool_choice"]): AnthropicToolChoice {
151+
if (!toolChoice) {
152+
return { type: "auto" };
153+
}
154+
155+
if (typeof toolChoice === "string") {
156+
switch (toolChoice) {
157+
case "auto":
158+
return { type: "auto" };
159+
case "none":
160+
// Anthropic doesn't have "none", so we'll omit tools entirely
161+
// This should be handled at a higher level
162+
return { type: "auto" };
163+
default:
164+
throw new Error(`Unsupported tool_choice string: ${toolChoice}`);
165+
}
166+
}
167+
168+
if (typeof toolChoice === "object" && toolChoice.type === "function") {
169+
return {
170+
type: "tool",
171+
name: toolChoice.function.name,
172+
};
173+
}
174+
175+
throw new Error("Unsupported tool_choice format");
176+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { toOpenAI } from "../../providers/anthropic/response/toOpenai";
2+
import { AntResponseBody } from "../../providers/anthropic/response/types";
3+
4+
export async function ant2oaiNonStream(response: Response): Promise<Response> {
5+
try {
6+
const anthropicBody = await response.json() as AntResponseBody;
7+
const openAIBody = toOpenAI(anthropicBody);
8+
9+
return new Response(JSON.stringify(openAIBody), {
10+
status: response.status,
11+
statusText: response.statusText,
12+
headers: {
13+
"content-type": "application/json",
14+
},
15+
});
16+
} catch (error) {
17+
console.error("Failed to transform Anthropic response:", error);
18+
return response;
19+
}
20+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { toOpenAI as toOpenAIStreamed } from "../../providers/anthropic/streamedResponse/toOpenai";
2+
import { AnthropicStreamEvent } from "../../providers/anthropic/streamedResponse/types";
3+
4+
export function ant2oaiStream(
5+
stream: ReadableStream<Uint8Array>
6+
): ReadableStream<Uint8Array> {
7+
const decoder = new TextDecoder();
8+
const encoder = new TextEncoder();
9+
let buffer = "";
10+
11+
return new ReadableStream({
12+
async start(controller) {
13+
const reader = stream.getReader();
14+
15+
try {
16+
while (true) {
17+
const { done, value } = await reader.read();
18+
19+
if (done) {
20+
// Process any remaining buffer
21+
if (buffer.trim()) {
22+
processBuffer(buffer, controller, encoder);
23+
}
24+
// Send the final [DONE] message
25+
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
26+
controller.close();
27+
break;
28+
}
29+
30+
// Decode the chunk and add to buffer
31+
buffer += decoder.decode(value, { stream: true });
32+
33+
// Process complete SSE messages (separated by double newlines)
34+
const messages = buffer.split("\n\n");
35+
36+
// Keep the last incomplete message in the buffer
37+
buffer = messages.pop() || "";
38+
39+
// Process complete messages
40+
for (const message of messages) {
41+
if (message.trim()) {
42+
processBuffer(message + "\n\n", controller, encoder);
43+
}
44+
}
45+
}
46+
} catch (error) {
47+
controller.error(error);
48+
} finally {
49+
reader.releaseLock();
50+
}
51+
},
52+
});
53+
}
54+
55+
function processBuffer(
56+
buffer: string,
57+
controller: ReadableStreamDefaultController<Uint8Array>,
58+
encoder: TextEncoder
59+
) {
60+
// Split by lines to find data lines
61+
const lines = buffer.split("\n");
62+
63+
for (const line of lines) {
64+
if (line.startsWith("data: ")) {
65+
try {
66+
const jsonStr = line.slice(6);
67+
68+
// Skip the [DONE] message from Anthropic
69+
if (jsonStr.trim() === "[DONE]") {
70+
continue;
71+
}
72+
73+
const anthropicEvent: AnthropicStreamEvent = JSON.parse(jsonStr);
74+
const openAIEvent = toOpenAIStreamed(anthropicEvent);
75+
76+
if (openAIEvent) {
77+
const sseMessage = `data: ${JSON.stringify(openAIEvent)}\n\n`;
78+
controller.enqueue(encoder.encode(sseMessage));
79+
}
80+
} catch (error) {
81+
// Skip malformed JSON
82+
console.error("Failed to parse SSE data:", error);
83+
}
84+
} else if (line.startsWith("event:") || line.startsWith(":")) {
85+
// Skip event type lines and comments
86+
continue;
87+
}
88+
}
89+
}
90+
91+
export function ant2oaiStreamResponse(response: Response): Response {
92+
if (!response.body) {
93+
return response;
94+
}
95+
96+
const transformedStream = ant2oaiStream(response.body);
97+
98+
return new Response(transformedStream, {
99+
status: response.status,
100+
statusText: response.statusText,
101+
headers: {
102+
"content-type": "text/event-stream; charset=utf-8",
103+
"cache-control": "no-cache",
104+
"connection": "keep-alive",
105+
},
106+
});
107+
}

0 commit comments

Comments
 (0)