commit b0713a844cc6a4e19cece03cd2718362e52e9ade Author: Kyle Carberry Date: Tue May 27 14:27:57 2025 -0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8bb9ce0 --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc1cc7c --- /dev/null +++ b/README.md @@ -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 + + + +## 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 `/` syntax. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..c89d121 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/demo.png b/demo.png new file mode 100644 index 0000000..9f77e9a Binary files /dev/null and b/demo.png differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..2dbb6ee --- /dev/null +++ b/package.json @@ -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" + } +} \ No newline at end of file diff --git a/src/anthropic-api-types.ts b/src/anthropic-api-types.ts new file mode 100644 index 0000000..76755e9 --- /dev/null +++ b/src/anthropic-api-types.ts @@ -0,0 +1,212 @@ +import type { JSONSchema7 } from "@ai-sdk/provider"; +import type { FinishReason } from "ai"; + +export type AnthropicMessagesPrompt = { + system: Array | 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; + 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; + 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"; + } +} diff --git a/src/anthropic-proxy.ts b/src/anthropic-proxy.ts new file mode 100644 index 0000000..b64c782 --- /dev/null +++ b/src/anthropic-proxy.ts @@ -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; + 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( + (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); + + 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}`; +}; diff --git a/src/claude-config.ts b/src/claude-config.ts new file mode 100644 index 0000000..fb98d7c --- /dev/null +++ b/src/claude-config.ts @@ -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; +}; diff --git a/src/convert-anthropic-messages.ts b/src/convert-anthropic-messages.ts new file mode 100644 index 0000000..1804a21 --- /dev/null +++ b/src/convert-anthropic-messages.ts @@ -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; +} { + const betas = new Set(); + 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; +}; +type AssistantBlock = { + type: "assistant"; + messages: Array; +}; +type UserBlock = { + type: "user"; + messages: Array; +}; + +function groupIntoBlocks( + prompt: LanguageModelV1Prompt +): Array { + const blocks: Array = []; + 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 +) { + const result: CoreMessage[] = []; + let toolCalls: Record = {}; + + 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; +} diff --git a/src/convert-to-anthropic-stream.ts b/src/convert-to-anthropic-stream.ts new file mode 100644 index 0000000..2712ba5 --- /dev/null +++ b/src/convert-to-anthropic-stream.ts @@ -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>> +): ReadableStream { + const transform = new TransformStream< + TextStreamPart>, + 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; +} diff --git a/src/convert-to-language-model-prompt.ts b/src/convert-to-language-model-prompt.ts new file mode 100644 index 0000000..4218eb2 --- /dev/null +++ b/src/convert-to-language-model-prompt.ts @@ -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, + }; + } + } +} diff --git a/src/data-content.ts b/src/data-content.ts new file mode 100644 index 0000000..4bf36da --- /dev/null +++ b/src/data-content.ts @@ -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 = 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"); + } +} diff --git a/src/detect-mimetype.ts b/src/detect-mimetype.ts new file mode 100644 index 0000000..29b85f2 --- /dev/null +++ b/src/detect-mimetype.ts @@ -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; +} diff --git a/src/invalid-data-content-error.ts b/src/invalid-data-content-error.ts new file mode 100644 index 0000000..85db045 --- /dev/null +++ b/src/invalid-data-content-error.ts @@ -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); + } +} diff --git a/src/json-schema.ts b/src/json-schema.ts new file mode 100644 index 0000000..477bda5 --- /dev/null +++ b/src/json-schema.ts @@ -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 = {}; + + // 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; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..9df7873 --- /dev/null +++ b/src/main.ts @@ -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 / e.g. openai/o3"); + } + + process.exit(code); + }); +} + diff --git a/src/split-data-url.ts b/src/split-data-url.ts new file mode 100644 index 0000000..2a76182 --- /dev/null +++ b/src/split-data-url.ts @@ -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, + }; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ab0f0b0 --- /dev/null +++ b/tsconfig.json @@ -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 + } +}