From b0713a844cc6a4e19cece03cd2718362e52e9ade Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 27 May 2025 14:27:57 -0400 Subject: [PATCH] Initial commit --- .gitignore | 34 ++ LICENSE | 20 + README.md | 48 +++ bun.lock | 87 +++++ demo.png | Bin 0 -> 40003 bytes package.json | 35 ++ src/anthropic-api-types.ts | 212 +++++++++++ src/anthropic-proxy.ts | 235 ++++++++++++ src/claude-config.ts | 9 + src/convert-anthropic-messages.ts | 461 ++++++++++++++++++++++++ src/convert-to-anthropic-stream.ts | 121 +++++++ src/convert-to-language-model-prompt.ts | 296 +++++++++++++++ src/data-content.ts | 91 +++++ src/detect-mimetype.ts | 136 +++++++ src/invalid-data-content-error.ts | 29 ++ src/json-schema.ts | 75 ++++ src/main.ts | 69 ++++ src/split-data-url.ts | 17 + tsconfig.json | 28 ++ 19 files changed, 2003 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 bun.lock create mode 100644 demo.png create mode 100644 package.json create mode 100644 src/anthropic-api-types.ts create mode 100644 src/anthropic-proxy.ts create mode 100644 src/claude-config.ts create mode 100644 src/convert-anthropic-messages.ts create mode 100644 src/convert-to-anthropic-stream.ts create mode 100644 src/convert-to-language-model-prompt.ts create mode 100644 src/data-content.ts create mode 100644 src/detect-mimetype.ts create mode 100644 src/invalid-data-content-error.ts create mode 100644 src/json-schema.ts create mode 100644 src/main.ts create mode 100644 src/split-data-url.ts create mode 100644 tsconfig.json 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 0000000000000000000000000000000000000000..9f77e9ae781571d4b35eaed2e3dd60e1030b78fb GIT binary patch literal 40003 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 BeP93p literal 0 HcmV?d00001 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 + } +}