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

34
.gitignore vendored Normal file
View file

@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

20
LICENSE Normal file
View file

@ -0,0 +1,20 @@
The MIT License
Copyright (c) 2025 Coder Technologies Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

48
README.md Normal file
View file

@ -0,0 +1,48 @@
# anyclaude
![NPM Version](https://img.shields.io/npm/v/anyclaude)
Use Claude Code with OpenAI, Google, xAI, and other providers.
- Extremely simple setup - just a basic command wrapper
- Uses the AI SDK for simple support of new providers
- Works with Claude Code GitHub Actions
<img src="./demo.png" width="65%">
## Get Started
```sh
# Use your favorite package manager (bun, pnpm, and npm are supported)
$ pnpm install -g anyclaude
# anyclaude is a wrapper for the Claude CLI
# `openai/`, `google/`, `xai/`, and `anthropic/` are supported
$ anyclaude --model openai/o3
```
Switch models in the Claude UI with `/model openai/o3`.
## FAQ
### What providers are supported?
See [the providers](./src/main.ts#L17) for the implementation.
- `GOOGLE_API_KEY` supports `google/*` models.
- `OPENAI_API_KEY` supports `openai/*` models.
- `XAI_API_KEY` supports `xai/*` models.
Set a custom OpenAI endpoint with `OPENAI_API_URL` to use OpenRouter
### How does this work?
Claude Code has added support for customizing the Anthropic endpoint with `ANTHROPIC_BASE_URL`.
anyclaude spawns a simple HTTP server that translates between Anthropic's format and the [AI SDK](https://github.com/vercel/ai) format, enabling support for any [AI SDK](https://github.com/vercel/ai) provider (e.g., Google, OpenAI, etc.)
## Do other models work better in Claude Code?
Not really, but it's fun to experiment with them.
`ANTHROPIC_MODEL` and `ANTHROPIC_SMALL_MODEL` are supported with the `<provider>/` syntax.

87
bun.lock Normal file
View file

@ -0,0 +1,87 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "openclaude",
"devDependencies": {
"@ai-sdk/anthropic": "^1.2.12",
"@ai-sdk/azure": "^1.3.23",
"@ai-sdk/google": "^1.2.18",
"@ai-sdk/openai": "^1.3.22",
"@ai-sdk/xai": "^1.2.16",
"@types/bun": "latest",
"@types/json-schema": "^7.0.15",
"ai": "^4.3.16",
"json-schema": "^0.4.0",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@1.2.12", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="],
"@ai-sdk/azure": ["@ai-sdk/azure@1.3.23", "", { "dependencies": { "@ai-sdk/openai": "1.3.22", "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-vpsaPtU24RBVk/IMM5UylR/N4RtAuL2NZLWc7LJ3tvMTHu6pI46a7w+1qIwR3F6yO9ehWR8qvfLaBefJNFxaVw=="],
"@ai-sdk/google": ["@ai-sdk/google@1.2.18", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-8B70+i+uB12Ae6Sn6B9Oc6W0W/XorGgc88Nx0pyUrcxFOdytHBaAVhTPqYsO3LLClfjYN8pQ9GMxd5cpGEnUcA=="],
"@ai-sdk/openai": ["@ai-sdk/openai@1.3.22", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-QwA+2EkG0QyjVR+7h6FE7iOu2ivNqAVMm9UJZkVxxTk5OIq5fFJDTEI/zICEMuHImTTXR2JjsL6EirJ28Jc4cw=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@0.2.14", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-icjObfMCHKSIbywijaoLdZ1nSnuRnWgMEMLgwoxPJgxsUHMx0aVORnsLUid4SPtdhHI3X2masrt6iaEQLvOSFw=="],
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
"@ai-sdk/react": ["@ai-sdk/react@1.2.12", "", { "dependencies": { "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/ui-utils": "1.2.11", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["zod"] }, "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g=="],
"@ai-sdk/ui-utils": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="],
"@ai-sdk/xai": ["@ai-sdk/xai@1.2.16", "", { "dependencies": { "@ai-sdk/openai-compatible": "0.2.14", "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-UOZT8td9PWwMi2dF9a0U44t/Oltmf6QmIJdSvrOcLG4mvpRc1UJn6YJaR0HtXs3YnW6SvY1zRdIDrW4GFpv4NA=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@types/bun": ["@types/bun@1.2.14", "", { "dependencies": { "bun-types": "1.2.14" } }, "sha512-VsFZKs8oKHzI7zwvECiAJ5oSorWndIWEVhfbYqZd4HI/45kzW7PN2Rr5biAzvGvRuNmYLSANY+H59ubHq8xw7Q=="],
"@types/diff-match-patch": ["@types/diff-match-patch@1.0.36", "", {}, "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@22.15.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ=="],
"ai": ["ai@4.3.16", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g=="],
"bun-types": ["bun-types@1.2.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-Kuh4Ub28ucMRWeiUUWMHsT9Wcbr4H3kLIO72RZZElSDxSu7vpetRvxIUDUaW6QtaIeixIpm7OXtNnZPf82EzwA=="],
"chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"jsondiffpatch": ["jsondiffpatch@0.6.0", "", { "dependencies": { "@types/diff-match-patch": "^1.0.36", "chalk": "^5.3.0", "diff-match-patch": "^1.0.5" }, "bin": { "jsondiffpatch": "bin/jsondiffpatch.js" } }, "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
"secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="],
"swr": ["swr@2.3.3", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A=="],
"throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
"zod": ["zod@3.25.30", "", {}, "sha512-VolhdEtu6TJr/fzGuHA/SZ5ixvXqA6ADOG9VRcQ3rdOKmF5hkmcJbyaQjUH5BgmpA9gej++zYRX7zjSmdReIwA=="],
"zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
}
}

BIN
demo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

35
package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "anyclaude",
"version": "1.0.4",
"author": {
"name": "coder",
"email": "support@coder.com",
"url": "https://coder.com"
},
"repository": {
"type": "git",
"url": "https://github.com/coder/anyclaude"
},
"bin": {
"anyclaude": "./dist/main.js"
},
"devDependencies": {
"@ai-sdk/anthropic": "^1.2.12",
"@ai-sdk/azure": "^1.3.23",
"@ai-sdk/google": "^1.2.18",
"@ai-sdk/openai": "^1.3.22",
"@ai-sdk/xai": "^1.2.16",
"@types/bun": "latest",
"@types/json-schema": "^7.0.15",
"ai": "^4.3.16",
"json-schema": "^0.4.0"
},
"peerDependencies": {
"typescript": "^5"
},
"description": "Run Claude Code with OpenAI, Google, xAI, and others.",
"license": "MIT",
"scripts": {
"build": "bun build --target node --outfile dist/main.js ./src/main.ts --format cjs && sed -i '0,/^/s//#!\\/usr\\/bin\\/env node\\n/' ./dist/main.js"
}
}

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,
};
}
}

28
tsconfig.json Normal file
View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["esnext"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}