Initial commit
This commit is contained in:
commit
b0713a844c
19 changed files with 2003 additions and 0 deletions
212
src/anthropic-api-types.ts
Normal file
212
src/anthropic-api-types.ts
Normal 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
235
src/anthropic-proxy.ts
Normal 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
9
src/claude-config.ts
Normal 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;
|
||||
};
|
||||
461
src/convert-anthropic-messages.ts
Normal file
461
src/convert-anthropic-messages.ts
Normal 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;
|
||||
}
|
||||
121
src/convert-to-anthropic-stream.ts
Normal file
121
src/convert-to-anthropic-stream.ts
Normal 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;
|
||||
}
|
||||
296
src/convert-to-language-model-prompt.ts
Normal file
296
src/convert-to-language-model-prompt.ts
Normal 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
91
src/data-content.ts
Normal 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
136
src/detect-mimetype.ts
Normal 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;
|
||||
}
|
||||
29
src/invalid-data-content-error.ts
Normal file
29
src/invalid-data-content-error.ts
Normal 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
75
src/json-schema.ts
Normal 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
69
src/main.ts
Normal 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
17
src/split-data-url.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue