Initial commit

This commit is contained in:
Kyle Carberry 2025-05-27 14:27:57 -04:00
commit b0713a844c
19 changed files with 2003 additions and 0 deletions

212
src/anthropic-api-types.ts Normal file
View file

@ -0,0 +1,212 @@
import type { JSONSchema7 } from "@ai-sdk/provider";
import type { FinishReason } from "ai";
export type AnthropicMessagesPrompt = {
system: Array<AnthropicTextContent> | undefined;
messages: AnthropicMessage[];
};
export type AnthropicMessage = AnthropicUserMessage | AnthropicAssistantMessage;
export type AnthropicCacheControl = { type: "ephemeral" };
export interface AnthropicUserMessage {
role: "user";
content: Array<
| AnthropicTextContent
| AnthropicImageContent
| AnthropicDocumentContent
| AnthropicToolResultContent
>;
}
export interface AnthropicAssistantMessage {
role: "assistant";
content: Array<
| AnthropicTextContent
| AnthropicThinkingContent
| AnthropicRedactedThinkingContent
| AnthropicToolCallContent
>;
}
export interface AnthropicTextContent {
type: "text";
text: string;
cache_control: AnthropicCacheControl | undefined;
}
export interface AnthropicThinkingContent {
type: "thinking";
thinking: string;
signature: string;
cache_control: AnthropicCacheControl | undefined;
}
export interface AnthropicRedactedThinkingContent {
type: "redacted_thinking";
data: string;
cache_control: AnthropicCacheControl | undefined;
}
type AnthropicContentSource =
| {
type: "base64";
media_type: string;
data: string;
}
| {
type: "url";
url: string;
};
export interface AnthropicImageContent {
type: "image";
source: AnthropicContentSource;
cache_control: AnthropicCacheControl | undefined;
}
export interface AnthropicDocumentContent {
type: "document";
source: AnthropicContentSource;
cache_control: AnthropicCacheControl | undefined;
}
export interface AnthropicToolCallContent {
type: "tool_use";
id: string;
name: string;
input: unknown;
cache_control: AnthropicCacheControl | undefined;
}
export interface AnthropicToolResultContent {
type: "tool_result";
tool_use_id: string;
content: string | Array<AnthropicTextContent | AnthropicImageContent>;
is_error: boolean | undefined;
cache_control: AnthropicCacheControl | undefined;
}
export type AnthropicTool =
| {
name: string;
description: string | undefined;
input_schema: JSONSchema7;
}
| {
name: string;
type: "computer_20250124" | "computer_20241022";
display_width_px: number;
display_height_px: number;
display_number: number;
}
| {
name: string;
type: "text_editor_20250124" | "text_editor_20241022";
}
| {
name: string;
type: "bash_20250124" | "bash_20241022";
};
export type AnthropicToolChoice =
| { type: "auto" | "any" }
| { type: "tool"; name: string };
export type AnthropicStreamUsage = {
input_tokens: number;
output_tokens: number;
};
export type AnthropicStreamChunk =
| {
type: "message_start";
message: AnthropicAssistantMessage & {
id: string;
model: string;
stop_reason: string | null;
stop_sequence: string | null;
usage: AnthropicStreamUsage;
};
}
| {
type: "content_block_start";
index: number;
content_block:
| {
type: "text";
text: string;
}
| {
type: "tool_use";
id: string;
name: string;
input: any;
};
}
| {
type: "content_block_delta";
index: number;
delta:
| {
type: "text_delta";
text: string;
}
| {
type: "input_json_delta";
partial_json: string;
};
}
| {
type: "content_block_stop";
index: number;
}
| {
type: "message_delta";
delta: {
stop_reason: string;
stop_sequence: string | null;
};
usage: AnthropicStreamUsage;
}
| {
type: "message_stop";
}
| {
type: "error";
error: {
type: "api_error";
message: string;
};
};
export type AnthropicMessagesRequest = {
model: string;
max_tokens: number;
messages: AnthropicMessage[];
temperature: number;
metadata: {
user_id: string;
};
system?: Array<AnthropicTextContent>;
tools?: Array<{
name: string;
description: string | undefined;
input_schema: JSONSchema7;
}>;
stream: boolean;
};
export function mapAnthropicStopReason(finishReason: FinishReason): string {
switch (finishReason) {
case "stop":
return "end_turn";
case "tool-calls":
return "tool_use";
case "length":
return "max_tokens";
default:
return "unknown";
}
}

235
src/anthropic-proxy.ts Normal file
View file

@ -0,0 +1,235 @@
import type { ProviderV1 } from "@ai-sdk/provider";
import { jsonSchema, streamText, type Tool } from "ai";
import * as http from "http";
import * as https from "https";
import type { AnthropicMessagesRequest } from "./anthropic-api-types";
import { mapAnthropicStopReason } from "./anthropic-api-types";
import {
convertFromAnthropicMessages,
convertToAnthropicMessagesPrompt,
} from "./convert-anthropic-messages";
import { convertToAnthropicStream } from "./convert-to-anthropic-stream";
import { convertToLanguageModelMessage } from "./convert-to-language-model-prompt";
import { providerizeSchema } from "./json-schema";
export type CreateAnthropicProxyOptions = {
providers: Record<string, ProviderV1>;
port?: number;
};
// createAnthropicProxy creates a proxy server that accepts
// Anthropic Message API requests and proxies them through
// the appropriate provider - converting the results back
// to the Anthropic Message API format.
export const createAnthropicProxy = ({
port,
providers,
}: CreateAnthropicProxyOptions): string => {
const proxy = http
.createServer((req, res) => {
if (!req.url) {
res.writeHead(400, {
"Content-Type": "application/json",
});
res.end(
JSON.stringify({
error: "No URL provided",
})
);
return;
}
const proxyToAnthropic = (body?: AnthropicMessagesRequest) => {
delete req.headers["host"];
const proxy = https.request(
{
host: "api.anthropic.com",
path: req.url,
method: req.method,
headers: req.headers,
},
(proxiedRes) => {
res.writeHead(proxiedRes.statusCode ?? 500, proxiedRes.headers);
proxiedRes.pipe(res, {
end: true,
});
}
);
if (body) {
proxy.end(JSON.stringify(body));
} else {
req.pipe(proxy, {
end: true,
});
}
};
if (!req.url.startsWith("/v1/messages")) {
proxyToAnthropic();
return;
}
(async () => {
const body = await new Promise<AnthropicMessagesRequest>(
(resolve, reject) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
resolve(JSON.parse(body));
});
req.on("error", (err) => {
reject(err);
});
}
);
const modelParts = body.model.split("/");
let providerName: string;
let model: string;
if (modelParts.length === 1) {
// If the user has the Anthropic provider configured,
// proxy all requests through there instead.
if (providers.anthropic) {
providerName = "anthropic";
model = modelParts[0]!;
} else {
// If they don't have it configured, just use
// the normal Anthropic API.
proxyToAnthropic(body);
}
return;
} else {
providerName = modelParts[0]!;
model = modelParts[1]!;
}
const provider = providers[providerName];
if (!provider) {
throw new Error(`Unknown provider: ${providerName}`);
}
const coreMessages = convertFromAnthropicMessages(body.messages);
let system: string | undefined;
if (body.system && body.system.length > 0) {
system = body.system.map((s) => s.text).join("\n");
}
const tools = body.tools?.reduce((acc, tool) => {
acc[tool.name] = {
description: tool.name,
parameters: jsonSchema(
providerizeSchema(providerName, tool.input_schema)
),
};
return acc;
}, {} as Record<string, Tool>);
const stream = streamText({
model: provider.languageModel(model),
system,
tools,
messages: coreMessages,
maxTokens: body.max_tokens,
temperature: body.temperature,
onFinish: ({ response, usage, finishReason }) => {
// If the body is already being streamed,
// we don't need to do any conversion here.
if (body.stream) {
return;
}
// There should only be one message.
const message = response.messages[0];
if (!message) {
throw new Error("No message found");
}
const prompt = convertToAnthropicMessagesPrompt({
prompt: [convertToLanguageModelMessage(message, {})],
sendReasoning: true,
warnings: [],
});
const promptMessage = prompt.prompt.messages[0];
if (!promptMessage) {
throw new Error("No prompt message found");
}
res.writeHead(200, { "Content-Type": "application/json" }).end(
JSON.stringify({
id: message.id,
type: "message",
role: promptMessage.role,
content: promptMessage.content,
model: body.model,
stop_reason: mapAnthropicStopReason(finishReason),
stop_sequence: null,
usage: {
input_tokens: usage.promptTokens,
output_tokens: usage.completionTokens,
},
})
);
},
onError: ({ error }) => {
res
.writeHead(400, {
"Content-Type": "application/json",
})
.end(
JSON.stringify({
type: "error",
error: error instanceof Error ? error.message : error,
})
);
},
});
if (!body.stream) {
await stream.consumeStream();
return;
}
res.on("error", () => {
// In NodeJS, this needs to be handled.
// We already send the error to the client.
});
await convertToAnthropicStream(stream.fullStream).pipeTo(
new WritableStream({
write(chunk) {
res.write(
`event: ${chunk.type}\ndata: ${JSON.stringify(chunk)}\n\n`
);
},
close() {
res.end();
},
})
);
})().catch((err) => {
res.writeHead(500, {
"Content-Type": "application/json",
});
res.end(
JSON.stringify({
error: "Internal server error: " + err.message,
})
);
});
})
.listen(port ?? 0);
const address = proxy.address();
if (!address) {
throw new Error("Failed to get proxy address");
}
if (typeof address === "string") {
return address;
}
return `http://localhost:${address.port}`;
};

9
src/claude-config.ts Normal file
View file

@ -0,0 +1,9 @@
import { readFileSync } from "fs";
import { homedir } from "os";
import path from "path";
export const readClaudeCodeAPIKey = (): string => {
const data = readFileSync(path.join(homedir(), ".claude.json"), "utf8");
const config = JSON.parse(data);
return config.primaryApiKey;
};

View file

@ -0,0 +1,461 @@
import {
type LanguageModelV1CallWarning,
type LanguageModelV1Message,
type LanguageModelV1Prompt,
type LanguageModelV1ProviderMetadata,
UnsupportedFunctionalityError,
} from "@ai-sdk/provider";
import { convertUint8ArrayToBase64 } from "@ai-sdk/provider-utils";
import type {
AnthropicAssistantMessage,
AnthropicCacheControl,
AnthropicMessage,
AnthropicMessagesPrompt,
AnthropicUserMessage,
} from "./anthropic-api-types";
import type { CoreMessage, FilePart, TextPart, ToolCallPart } from "ai";
import type { ReasoningUIPart } from "@ai-sdk/ui-utils";
export function convertToAnthropicMessagesPrompt({
prompt,
sendReasoning,
warnings,
}: {
prompt: LanguageModelV1Prompt;
sendReasoning: boolean;
warnings: LanguageModelV1CallWarning[];
}): {
prompt: AnthropicMessagesPrompt;
betas: Set<string>;
} {
const betas = new Set<string>();
const blocks = groupIntoBlocks(prompt);
let system: AnthropicMessagesPrompt["system"] = undefined;
const messages: AnthropicMessagesPrompt["messages"] = [];
function getCacheControl(
providerMetadata: LanguageModelV1ProviderMetadata | undefined
): AnthropicCacheControl | undefined {
const anthropic = providerMetadata?.anthropic;
// allow both cacheControl and cache_control:
const cacheControlValue =
anthropic?.cacheControl ?? anthropic?.cache_control;
// Pass through value assuming it is of the correct type.
// The Anthropic API will validate the value.
return cacheControlValue as AnthropicCacheControl | undefined;
}
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i]!;
const isLastBlock = i === blocks.length - 1;
const type = block.type;
switch (type) {
case "system": {
if (system != null) {
throw new UnsupportedFunctionalityError({
functionality:
"Multiple system messages that are separated by user/assistant messages",
});
}
system = block.messages.map(({ content, providerMetadata }) => ({
type: "text",
text: content,
cache_control: getCacheControl(providerMetadata),
}));
break;
}
case "user": {
// combines all user and tool messages in this block into a single message:
const anthropicContent: AnthropicUserMessage["content"] = [];
for (const message of block.messages) {
const { role, content } = message;
switch (role) {
case "user": {
for (let j = 0; j < content.length; j++) {
const part = content[j]!;
// cache control: first add cache control from part.
// for the last part of a message,
// check also if the message has cache control.
const isLastPart = j === content.length - 1;
const cacheControl =
getCacheControl(part.providerMetadata) ??
(isLastPart
? getCacheControl(message.providerMetadata)
: undefined);
switch (part.type) {
case "text": {
anthropicContent.push({
type: "text",
text: part.text,
cache_control: cacheControl,
});
break;
}
case "image": {
anthropicContent.push({
type: "image",
source:
part.image instanceof URL
? {
type: "url",
url: part.image.toString(),
}
: {
type: "base64",
media_type: part.mimeType ?? "image/jpeg",
data: convertUint8ArrayToBase64(part.image),
},
cache_control: cacheControl,
});
break;
}
case "file": {
if (part.mimeType !== "application/pdf") {
throw new UnsupportedFunctionalityError({
functionality: "Non-PDF files in user messages",
});
}
betas.add("pdfs-2024-09-25");
anthropicContent.push({
type: "document",
source:
part.data instanceof URL
? {
type: "url",
url: part.data.toString(),
}
: {
type: "base64",
media_type: "application/pdf",
data: part.data,
},
cache_control: cacheControl,
});
break;
}
}
}
break;
}
case "tool": {
for (let i = 0; i < content.length; i++) {
const part = content[i]!;
// cache control: first add cache control from part.
// for the last part of a message,
// check also if the message has cache control.
const isLastPart = i === content.length - 1;
const cacheControl =
getCacheControl(part.providerMetadata) ??
(isLastPart
? getCacheControl(message.providerMetadata)
: undefined);
const toolResultContent =
part.content != null
? part.content.map((part) => {
switch (part.type) {
case "text":
return {
type: "text" as const,
text: part.text,
cache_control: undefined,
};
case "image":
return {
type: "image" as const,
source: {
type: "base64" as const,
media_type: part.mimeType ?? "image/jpeg",
data: part.data,
},
cache_control: undefined,
};
}
})
: JSON.stringify(part.result);
anthropicContent.push({
type: "tool_result",
tool_use_id: part.toolCallId,
content: toolResultContent,
is_error: part.isError,
cache_control: cacheControl,
});
}
break;
}
default: {
const _exhaustiveCheck: never = role;
throw new Error(`Unsupported role: ${_exhaustiveCheck}`);
}
}
}
messages.push({ role: "user", content: anthropicContent });
break;
}
case "assistant": {
// combines multiple assistant messages in this block into a single message:
const anthropicContent: AnthropicAssistantMessage["content"] = [];
for (let j = 0; j < block.messages.length; j++) {
const message = block.messages[j]!;
const isLastMessage = j === block.messages.length - 1;
const { content } = message;
for (let k = 0; k < content.length; k++) {
const part = content[k]!;
const isLastContentPart = k === content.length - 1;
// cache control: first add cache control from part.
// for the last part of a message,
// check also if the message has cache control.
const cacheControl =
getCacheControl(part.providerMetadata) ??
(isLastContentPart
? getCacheControl(message.providerMetadata)
: undefined);
switch (part.type) {
case "text": {
anthropicContent.push({
type: "text",
text:
// trim the last text part if it's the last message in the block
// because Anthropic does not allow trailing whitespace
// in pre-filled assistant responses
isLastBlock && isLastMessage && isLastContentPart
? part.text.trim()
: part.text,
cache_control: cacheControl,
});
break;
}
case "reasoning": {
if (sendReasoning) {
anthropicContent.push({
type: "thinking",
thinking: part.text,
signature: part.signature!,
cache_control: cacheControl,
});
} else {
warnings.push({
type: "other",
message:
"sending reasoning content is disabled for this model",
});
}
break;
}
case "redacted-reasoning": {
anthropicContent.push({
type: "redacted_thinking",
data: part.data,
cache_control: cacheControl,
});
break;
}
case "tool-call": {
anthropicContent.push({
type: "tool_use",
id: part.toolCallId,
name: part.toolName,
input: part.args,
cache_control: cacheControl,
});
break;
}
}
}
}
messages.push({ role: "assistant", content: anthropicContent });
break;
}
default: {
const _exhaustiveCheck: never = type;
throw new Error(`Unsupported type: ${_exhaustiveCheck}`);
}
}
}
return {
prompt: { system, messages },
betas,
};
}
type SystemBlock = {
type: "system";
messages: Array<LanguageModelV1Message & { role: "system" }>;
};
type AssistantBlock = {
type: "assistant";
messages: Array<LanguageModelV1Message & { role: "assistant" }>;
};
type UserBlock = {
type: "user";
messages: Array<LanguageModelV1Message & { role: "user" | "tool" }>;
};
function groupIntoBlocks(
prompt: LanguageModelV1Prompt
): Array<SystemBlock | AssistantBlock | UserBlock> {
const blocks: Array<SystemBlock | AssistantBlock | UserBlock> = [];
let currentBlock: SystemBlock | AssistantBlock | UserBlock | undefined =
undefined;
for (const message of prompt) {
const { role } = message;
switch (role) {
case "system": {
if (currentBlock?.type !== "system") {
currentBlock = { type: "system", messages: [] };
blocks.push(currentBlock);
}
currentBlock.messages.push(message);
break;
}
case "assistant": {
if (currentBlock?.type !== "assistant") {
currentBlock = { type: "assistant", messages: [] };
blocks.push(currentBlock);
}
currentBlock.messages.push(message);
break;
}
case "user": {
if (currentBlock?.type !== "user") {
currentBlock = { type: "user", messages: [] };
blocks.push(currentBlock);
}
currentBlock.messages.push(message);
break;
}
case "tool": {
if (currentBlock?.type !== "user") {
currentBlock = { type: "user", messages: [] };
blocks.push(currentBlock);
}
currentBlock.messages.push(message);
break;
}
default: {
const _exhaustiveCheck: never = role;
throw new Error(`Unsupported role: ${_exhaustiveCheck}`);
}
}
}
return blocks;
}
export function convertFromAnthropicMessages(
messages: ReadonlyArray<AnthropicMessage>
) {
const result: CoreMessage[] = [];
let toolCalls: Record<string, ToolCallPart> = {};
for (const message of messages) {
const messageContent: (
| TextPart
| FilePart
| ReasoningUIPart
| ToolCallPart
)[] = [];
if (typeof message.content !== "string") {
message.content.forEach((content) => {
switch (content.type) {
case "text": {
messageContent.push({
type: "text",
text: content.text,
});
break;
}
case "tool_use": {
messageContent.push({
type: "tool-call",
args: content.input,
toolCallId: content.id,
toolName: content.name,
});
toolCalls[content.id] = {
type: "tool-call",
args: content.input,
toolCallId: content.id,
toolName: content.name,
};
break;
}
case "tool_result": {
const toolCall = toolCalls[content.tool_use_id];
if (!toolCall) {
throw new Error("Tool call not found");
}
result.push({
role: "tool",
content: [
{
result: content.content,
toolCallId: content.tool_use_id,
toolName: toolCall.toolName,
type: "tool-result",
},
],
});
break;
}
}
});
} else {
messageContent.push({
type: "text",
text: message.content as string,
});
}
if (messageContent.length > 0) {
result.push({
role: message.role,
content: messageContent,
} as CoreMessage);
}
}
return result;
}

View file

@ -0,0 +1,121 @@
import type { Tool } from "ai";
import type { TextStreamPart } from "ai";
import {
mapAnthropicStopReason,
type AnthropicStreamChunk,
} from "./anthropic-api-types";
export function convertToAnthropicStream(
stream: ReadableStream<TextStreamPart<Record<string, Tool>>>
): ReadableStream<AnthropicStreamChunk> {
const transform = new TransformStream<
TextStreamPart<Record<string, Tool>>,
AnthropicStreamChunk
>({
transform(chunk, controller) {
let index = 0;
switch (chunk.type) {
case "step-start":
controller.enqueue({
type: "message_start",
message: {
id: chunk.messageId,
role: "assistant",
content: [],
model: "claude-4-sonnet-20250514",
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 0,
output_tokens: 0,
},
},
});
break;
case "step-finish":
controller.enqueue({
type: "message_delta",
delta: {
stop_reason: mapAnthropicStopReason(chunk.finishReason),
stop_sequence: null,
},
usage: {
input_tokens: chunk.usage.promptTokens,
output_tokens: chunk.usage.completionTokens,
},
});
index++;
break;
case "finish":
controller.enqueue({
type: "message_stop",
});
break;
case "text-delta":
controller.enqueue({
type: "content_block_delta",
index: index,
delta: {
type: "text_delta",
text: chunk.textDelta,
},
});
break;
case "tool-call-streaming-start":
controller.enqueue({
type: "content_block_start",
index: index,
content_block: {
type: "tool_use",
id: chunk.toolCallId,
name: chunk.toolName,
input: {},
},
});
break;
case "tool-call-delta":
controller.enqueue({
type: "content_block_delta",
index: index,
delta: {
type: "input_json_delta",
partial_json: chunk.argsTextDelta,
},
});
break;
case "tool-call":
controller.enqueue({
type: "content_block_start",
index: index,
content_block: {
type: "tool_use",
id: chunk.toolCallId,
name: chunk.toolName,
input: chunk.args,
},
});
index++;
break;
case "error":
controller.enqueue({
type: "error",
error: {
type: "api_error",
message:
chunk.error instanceof Error
? chunk.error.message
: chunk.error as string,
},
});
break;
default:
controller.error(new Error(`Unhandled chunk type: ${chunk.type}`));
}
},
});
stream.pipeTo(transform.writable).catch((err) => {
console.log("WE GOT AN ERROR");
});
return transform.readable;
}

View file

@ -0,0 +1,296 @@
import type {
LanguageModelV1FilePart,
LanguageModelV1ImagePart,
LanguageModelV1Message,
LanguageModelV1TextPart,
} from "@ai-sdk/provider";
import {
InvalidMessageRoleError,
type CoreMessage,
type DataContent,
type FilePart,
type ImagePart,
type TextPart,
} from "ai";
import {
convertDataContentToBase64String,
convertDataContentToUint8Array,
} from "./data-content";
import { detectMimeType, imageMimeTypeSignatures } from "./detect-mimetype";
import { splitDataUrl } from "./split-data-url";
/**
* Convert a CoreMessage to a LanguageModelV1Message.
*
* @param message The CoreMessage to convert.
* @param downloadedAssets A map of URLs to their downloaded data. Only
* available if the model does not support URLs, null otherwise.
*/
export function convertToLanguageModelMessage(
message: CoreMessage,
downloadedAssets: Record<
string,
{ mimeType: string | undefined; data: Uint8Array }
>
): LanguageModelV1Message {
const role = message.role;
switch (role) {
case "system": {
return {
role: "system",
content: message.content,
providerMetadata:
message.providerOptions ?? message.experimental_providerMetadata,
};
}
case "user": {
if (typeof message.content === "string") {
return {
role: "user",
content: [{ type: "text", text: message.content }],
providerMetadata:
message.providerOptions ?? message.experimental_providerMetadata,
};
}
return {
role: "user",
content: message.content
.map((part) => convertPartToLanguageModelPart(part, downloadedAssets))
// remove empty text parts:
.filter((part) => part.type !== "text" || part.text !== ""),
providerMetadata:
message.providerOptions ?? message.experimental_providerMetadata,
};
}
case "assistant": {
if (typeof message.content === "string") {
return {
role: "assistant",
content: [{ type: "text", text: message.content }],
providerMetadata:
message.providerOptions ?? message.experimental_providerMetadata,
};
}
return {
role: "assistant",
content: message.content
.filter(
// remove empty text parts:
(part) => part.type !== "text" || part.text !== ""
)
.map((part) => {
const providerOptions =
part.providerOptions ?? part.experimental_providerMetadata;
switch (part.type) {
case "file": {
return {
type: "file",
data:
part.data instanceof URL
? part.data
: convertDataContentToBase64String(part.data),
filename: part.filename,
mimeType: part.mimeType,
providerMetadata: providerOptions,
};
}
case "reasoning": {
return {
type: "reasoning",
text: part.text,
signature: part.signature,
providerMetadata: providerOptions,
};
}
case "redacted-reasoning": {
return {
type: "redacted-reasoning",
data: part.data,
providerMetadata: providerOptions,
};
}
case "text": {
return {
type: "text" as const,
text: part.text,
providerMetadata: providerOptions,
};
}
case "tool-call": {
return {
type: "tool-call" as const,
toolCallId: part.toolCallId,
toolName: part.toolName,
args: part.args,
providerMetadata: providerOptions,
};
}
}
}),
providerMetadata:
message.providerOptions ?? message.experimental_providerMetadata,
};
}
case "tool": {
return {
role: "tool",
content: message.content.map((part) => ({
type: "tool-result",
toolCallId: part.toolCallId,
toolName: part.toolName,
result: part.result,
content: part.experimental_content,
isError: part.isError,
providerMetadata:
part.providerOptions ?? part.experimental_providerMetadata,
})),
providerMetadata:
message.providerOptions ?? message.experimental_providerMetadata,
};
}
default: {
const _exhaustiveCheck: never = role;
throw new InvalidMessageRoleError({ role: _exhaustiveCheck });
}
}
}
/**
* Convert part of a message to a LanguageModelV1Part.
* @param part The part to convert.
* @param downloadedAssets A map of URLs to their downloaded data. Only
* available if the model does not support URLs, null otherwise.
*
* @returns The converted part.
*/
function convertPartToLanguageModelPart(
part: TextPart | ImagePart | FilePart,
downloadedAssets: Record<
string,
{ mimeType: string | undefined; data: Uint8Array }
>
):
| LanguageModelV1TextPart
| LanguageModelV1ImagePart
| LanguageModelV1FilePart {
if (part.type === "text") {
return {
type: "text",
text: part.text,
providerMetadata:
part.providerOptions ?? part.experimental_providerMetadata,
};
}
let mimeType: string | undefined = part.mimeType;
let data: DataContent | URL;
let content: URL | ArrayBuffer | string;
let normalizedData: Uint8Array | URL;
const type = part.type;
switch (type) {
case "image":
data = part.image;
break;
case "file":
data = part.data;
break;
default:
throw new Error(`Unsupported part type: ${type}`);
}
// Attempt to create a URL from the data. If it fails, we can assume the data
// is not a URL and likely some other sort of data.
try {
content = typeof data === "string" ? new URL(data) : (data as ArrayBuffer);
} catch (error) {
content = data as ArrayBuffer;
}
// If we successfully created a URL, we can use that to normalize the data
// either by passing it through or converting normalizing the base64 content
// to a Uint8Array.
if (content instanceof URL) {
// If the content is a data URL, we want to convert that to a Uint8Array
if (content.protocol === "data:") {
const { mimeType: dataUrlMimeType, base64Content } = splitDataUrl(
content.toString()
);
if (dataUrlMimeType == null || base64Content == null) {
throw new Error(`Invalid data URL format in part ${type}`);
}
mimeType = dataUrlMimeType;
normalizedData = convertDataContentToUint8Array(base64Content);
} else {
/**
* If the content is a URL, we should first see if it was downloaded. And if not,
* we can let the model decide if it wants to support the URL. This also allows
* for non-HTTP URLs to be passed through (e.g. gs://).
*/
const downloadedFile = downloadedAssets[content.toString()];
if (downloadedFile) {
normalizedData = downloadedFile.data;
mimeType ??= downloadedFile.mimeType;
} else {
normalizedData = content;
}
}
} else {
// Since we know now the content is not a URL, we can attempt to normalize
// the data assuming it is some sort of data.
normalizedData = convertDataContentToUint8Array(content);
}
// Now that we have the normalized data either as a URL or a Uint8Array,
// we can create the LanguageModelV1Part.
switch (type) {
case "image": {
// When possible, try to detect the mimetype automatically
// to deal with incorrect mimetype inputs.
// When detection fails, use provided mimetype.
if (normalizedData instanceof Uint8Array) {
mimeType =
detectMimeType({
data: normalizedData,
signatures: imageMimeTypeSignatures,
}) ?? mimeType;
}
return {
type: "image",
image: normalizedData,
mimeType,
providerMetadata:
part.providerOptions ?? part.experimental_providerMetadata,
};
}
case "file": {
// We should have a mimeType at this point, if not, throw an error.
if (mimeType == null) {
throw new Error(`Mime type is missing for file part`);
}
return {
type: "file",
data:
normalizedData instanceof Uint8Array
? convertDataContentToBase64String(normalizedData)
: normalizedData,
filename: part.filename,
mimeType,
providerMetadata:
part.providerOptions ?? part.experimental_providerMetadata,
};
}
}
}

91
src/data-content.ts Normal file
View file

@ -0,0 +1,91 @@
import {
convertBase64ToUint8Array,
convertUint8ArrayToBase64,
} from "@ai-sdk/provider-utils";
import { InvalidDataContentError } from "./invalid-data-content-error";
import { z } from "zod";
/**
Data content. Can either be a base64-encoded string, a Uint8Array, an ArrayBuffer, or a Buffer.
*/
export type DataContent = string | Uint8Array | ArrayBuffer | Buffer;
/**
@internal
*/
export const dataContentSchema: z.ZodType<DataContent> = z.union([
z.string(),
z.instanceof(Uint8Array),
z.instanceof(ArrayBuffer),
z.custom(
// Buffer might not be available in some environments such as CloudFlare:
(value: unknown): value is Buffer =>
globalThis.Buffer?.isBuffer(value) ?? false,
{ message: "Must be a Buffer" }
),
]);
/**
Converts data content to a base64-encoded string.
@param content - Data content to convert.
@returns Base64-encoded string.
*/
export function convertDataContentToBase64String(content: DataContent): string {
if (typeof content === "string") {
return content;
}
if (content instanceof ArrayBuffer) {
return convertUint8ArrayToBase64(new Uint8Array(content));
}
return convertUint8ArrayToBase64(content);
}
/**
Converts data content to a Uint8Array.
@param content - Data content to convert.
@returns Uint8Array.
*/
export function convertDataContentToUint8Array(
content: DataContent
): Uint8Array {
if (content instanceof Uint8Array) {
return content;
}
if (typeof content === "string") {
try {
return convertBase64ToUint8Array(content);
} catch (error) {
throw new InvalidDataContentError({
message:
"Invalid data content. Content string is not a base64-encoded media.",
content,
cause: error,
});
}
}
if (content instanceof ArrayBuffer) {
return new Uint8Array(content);
}
throw new InvalidDataContentError({ content });
}
/**
* Converts a Uint8Array to a string of text.
*
* @param uint8Array - The Uint8Array to convert.
* @returns The converted string.
*/
export function convertUint8ArrayToText(uint8Array: Uint8Array): string {
try {
return new TextDecoder().decode(uint8Array);
} catch (error) {
throw new Error("Error decoding Uint8Array to text");
}
}

136
src/detect-mimetype.ts Normal file
View file

@ -0,0 +1,136 @@
import { convertBase64ToUint8Array } from "@ai-sdk/provider-utils";
export const imageMimeTypeSignatures = [
{
mimeType: "image/gif" as const,
bytesPrefix: [0x47, 0x49, 0x46],
base64Prefix: "R0lG",
},
{
mimeType: "image/png" as const,
bytesPrefix: [0x89, 0x50, 0x4e, 0x47],
base64Prefix: "iVBORw",
},
{
mimeType: "image/jpeg" as const,
bytesPrefix: [0xff, 0xd8],
base64Prefix: "/9j/",
},
{
mimeType: "image/webp" as const,
bytesPrefix: [0x52, 0x49, 0x46, 0x46],
base64Prefix: "UklGRg",
},
{
mimeType: "image/bmp" as const,
bytesPrefix: [0x42, 0x4d],
base64Prefix: "Qk",
},
{
mimeType: "image/tiff" as const,
bytesPrefix: [0x49, 0x49, 0x2a, 0x00],
base64Prefix: "SUkqAA",
},
{
mimeType: "image/tiff" as const,
bytesPrefix: [0x4d, 0x4d, 0x00, 0x2a],
base64Prefix: "TU0AKg",
},
{
mimeType: "image/avif" as const,
bytesPrefix: [
0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66,
],
base64Prefix: "AAAAIGZ0eXBhdmlm",
},
{
mimeType: "image/heic" as const,
bytesPrefix: [
0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63,
],
base64Prefix: "AAAAIGZ0eXBoZWlj",
},
] as const;
export const audioMimeTypeSignatures = [
{
mimeType: "audio/mpeg" as const,
bytesPrefix: [0xff, 0xfb],
base64Prefix: "//s=",
},
{
mimeType: "audio/wav" as const,
bytesPrefix: [0x52, 0x49, 0x46, 0x46],
base64Prefix: "UklGR",
},
{
mimeType: "audio/ogg" as const,
bytesPrefix: [0x4f, 0x67, 0x67, 0x53],
base64Prefix: "T2dnUw",
},
{
mimeType: "audio/flac" as const,
bytesPrefix: [0x66, 0x4c, 0x61, 0x43],
base64Prefix: "ZkxhQw",
},
{
mimeType: "audio/aac" as const,
bytesPrefix: [0x40, 0x15, 0x00, 0x00],
base64Prefix: "QBUA",
},
{
mimeType: "audio/mp4" as const,
bytesPrefix: [0x66, 0x74, 0x79, 0x70],
base64Prefix: "ZnR5cA",
},
] as const;
const stripID3 = (data: Uint8Array | string) => {
const bytes =
typeof data === "string" ? convertBase64ToUint8Array(data) : data;
const id3Size =
((bytes[6]! & 0x7f) << 21) |
((bytes[7]! & 0x7f) << 14) |
((bytes[8]! & 0x7f) << 7) |
(bytes[9]! & 0x7f);
// The raw MP3 starts here
return bytes.slice(id3Size + 10);
};
function stripID3TagsIfPresent(data: Uint8Array | string): Uint8Array | string {
const hasId3 =
(typeof data === "string" && data.startsWith("SUQz")) ||
(typeof data !== "string" &&
data.length > 10 &&
data[0] === 0x49 && // 'I'
data[1] === 0x44 && // 'D'
data[2] === 0x33); // '3'
return hasId3 ? stripID3(data) : data;
}
export function detectMimeType({
data,
signatures,
}: {
data: Uint8Array | string;
signatures: typeof audioMimeTypeSignatures | typeof imageMimeTypeSignatures;
}): (typeof signatures)[number]["mimeType"] | undefined {
const processedData = stripID3TagsIfPresent(data);
for (const signature of signatures) {
if (
typeof processedData === "string"
? processedData.startsWith(signature.base64Prefix)
: processedData.length >= signature.bytesPrefix.length &&
signature.bytesPrefix.every(
(byte, index) => processedData[index] === byte
)
) {
return signature.mimeType;
}
}
return undefined;
}

View file

@ -0,0 +1,29 @@
import { AISDKError } from "@ai-sdk/provider";
const name = "AI_InvalidDataContentError";
const marker = `vercel.ai.error.${name}`;
const symbol = Symbol.for(marker);
export class InvalidDataContentError extends AISDKError {
private readonly [symbol] = true; // used in isInstance
readonly content: unknown;
constructor({
content,
cause,
message = `Invalid data content. Expected a base64 string, Uint8Array, ArrayBuffer, or Buffer, but got ${typeof content}.`,
}: {
content: unknown;
cause?: unknown;
message?: string;
}) {
super({ name, message, cause });
this.content = content;
}
static isInstance(error: unknown): error is InvalidDataContentError {
return AISDKError.hasMarker(error, marker);
}
}

75
src/json-schema.ts Normal file
View file

@ -0,0 +1,75 @@
import type { JSONSchema7 } from "json-schema";
export function providerizeSchema(
provider: string,
schema: JSONSchema7
): JSONSchema7 {
// Handle primitive types or schemas without properties
if (
!schema ||
typeof schema !== "object" ||
schema.type !== "object" ||
!schema.properties
) {
return schema;
}
const processedProperties: Record<string, JSONSchema7> = {};
// Recursively process each property
for (const [key, property] of Object.entries(schema.properties)) {
if (typeof property === "object" && property !== null) {
let processedProperty = property as JSONSchema7;
// Remove uri format for OpenAI
if (provider === "openai" && processedProperty.format === "uri") {
processedProperty = { ...processedProperty };
delete processedProperty.format;
}
if (processedProperty.type === "object") {
// Recursively process nested objects
processedProperties[key] = providerizeSchema(
provider,
processedProperty
);
} else if (
processedProperty.type === "array" &&
processedProperty.items
) {
// Handle arrays with object items
const items = processedProperty.items;
if (
typeof items === "object" &&
!Array.isArray(items) &&
items.type === "object"
) {
processedProperties[key] = {
...processedProperty,
items: providerizeSchema(provider, items as JSONSchema7),
};
} else {
processedProperties[key] = processedProperty;
}
} else {
processedProperties[key] = processedProperty;
}
} else {
// Handle boolean properties (true/false schemas)
processedProperties[key] = property as unknown as JSONSchema7;
}
}
const result: JSONSchema7 = {
...schema,
properties: processedProperties,
};
// Only add required properties for OpenAI
if (provider === "openai") {
result.required = Object.keys(schema.properties);
result.additionalProperties = false;
}
return result;
}

69
src/main.ts Normal file
View file

@ -0,0 +1,69 @@
// This is just intended to execute Claude Code while setting up a proxy for tokens.
import { createAnthropic } from "@ai-sdk/anthropic";
import { createAzure } from "@ai-sdk/azure";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createOpenAI } from "@ai-sdk/openai";
import { createXai } from "@ai-sdk/xai";
import { spawn } from "child_process";
import {
createAnthropicProxy,
type CreateAnthropicProxyOptions,
} from "./anthropic-proxy";
// providers are supported providers to proxy requests by name.
// Model names are split when requested by `/`. The provider
// name is the first part, and the rest is the model name.
const providers: CreateAnthropicProxyOptions["providers"] = {
openai: createOpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_API_URL,
}),
azure: createAzure({
apiKey: process.env.AZURE_API_KEY,
baseURL: process.env.AZURE_API_URL,
}),
google: createGoogleGenerativeAI({
apiKey: process.env.GOOGLE_API_KEY,
baseURL: process.env.GOOGLE_API_URL,
}),
xai: createXai({
apiKey: process.env.XAI_API_KEY,
baseURL: process.env.XAI_API_URL,
}),
};
// We exclude this by default, because the Claude Code
// API key is not supported by Anthropic endpoints.
if (process.env.ANTHROPIC_API_KEY) {
providers.anthropic = createAnthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
baseURL: process.env.ANTHROPIC_API_URL,
});
}
const proxyURL = createAnthropicProxy({
providers,
});
if (process.env.PROXY_ONLY === "true") {
console.log("Proxy only mode: "+proxyURL);
} else {
const claudeArgs = process.argv.slice(2);
const proc = spawn("claude", claudeArgs, {
env: {
...process.env,
ANTHROPIC_BASE_URL: proxyURL,
},
stdio: "inherit",
});
proc.on("exit", (code) => {
if (claudeArgs[0] === "-h" || claudeArgs[0] === "--help") {
console.log("\nCustom Models:")
console.log(" --model <provider>/<model> e.g. openai/o3");
}
process.exit(code);
});
}

17
src/split-data-url.ts Normal file
View file

@ -0,0 +1,17 @@
export function splitDataUrl(dataUrl: string): {
mimeType: string | undefined;
base64Content: string | undefined;
} {
try {
const [header, base64Content] = dataUrl.split(",");
return {
mimeType: header?.split(";")[0]?.split(":")[1],
base64Content,
};
} catch (error) {
return {
mimeType: undefined,
base64Content: undefined,
};
}
}