Initial commit
This commit is contained in:
commit
b0713a844c
19 changed files with 2003 additions and 0 deletions
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
20
LICENSE
Normal file
20
LICENSE
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
The MIT License
|
||||
|
||||
Copyright (c) 2025 Coder Technologies Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
48
README.md
Normal file
48
README.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# anyclaude
|
||||
|
||||

|
||||
|
||||
Use Claude Code with OpenAI, Google, xAI, and other providers.
|
||||
|
||||
- Extremely simple setup - just a basic command wrapper
|
||||
- Uses the AI SDK for simple support of new providers
|
||||
- Works with Claude Code GitHub Actions
|
||||
|
||||
<img src="./demo.png" width="65%">
|
||||
|
||||
## Get Started
|
||||
|
||||
```sh
|
||||
# Use your favorite package manager (bun, pnpm, and npm are supported)
|
||||
$ pnpm install -g anyclaude
|
||||
|
||||
# anyclaude is a wrapper for the Claude CLI
|
||||
# `openai/`, `google/`, `xai/`, and `anthropic/` are supported
|
||||
$ anyclaude --model openai/o3
|
||||
```
|
||||
|
||||
Switch models in the Claude UI with `/model openai/o3`.
|
||||
|
||||
## FAQ
|
||||
|
||||
### What providers are supported?
|
||||
|
||||
See [the providers](./src/main.ts#L17) for the implementation.
|
||||
|
||||
- `GOOGLE_API_KEY` supports `google/*` models.
|
||||
- `OPENAI_API_KEY` supports `openai/*` models.
|
||||
- `XAI_API_KEY` supports `xai/*` models.
|
||||
|
||||
Set a custom OpenAI endpoint with `OPENAI_API_URL` to use OpenRouter
|
||||
|
||||
### How does this work?
|
||||
|
||||
Claude Code has added support for customizing the Anthropic endpoint with `ANTHROPIC_BASE_URL`.
|
||||
|
||||
anyclaude spawns a simple HTTP server that translates between Anthropic's format and the [AI SDK](https://github.com/vercel/ai) format, enabling support for any [AI SDK](https://github.com/vercel/ai) provider (e.g., Google, OpenAI, etc.)
|
||||
|
||||
## Do other models work better in Claude Code?
|
||||
|
||||
Not really, but it's fun to experiment with them.
|
||||
|
||||
`ANTHROPIC_MODEL` and `ANTHROPIC_SMALL_MODEL` are supported with the `<provider>/` syntax.
|
||||
87
bun.lock
Normal file
87
bun.lock
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "openclaude",
|
||||
"devDependencies": {
|
||||
"@ai-sdk/anthropic": "^1.2.12",
|
||||
"@ai-sdk/azure": "^1.3.23",
|
||||
"@ai-sdk/google": "^1.2.18",
|
||||
"@ai-sdk/openai": "^1.3.22",
|
||||
"@ai-sdk/xai": "^1.2.16",
|
||||
"@types/bun": "latest",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"ai": "^4.3.16",
|
||||
"json-schema": "^0.4.0",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@1.2.12", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="],
|
||||
|
||||
"@ai-sdk/azure": ["@ai-sdk/azure@1.3.23", "", { "dependencies": { "@ai-sdk/openai": "1.3.22", "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-vpsaPtU24RBVk/IMM5UylR/N4RtAuL2NZLWc7LJ3tvMTHu6pI46a7w+1qIwR3F6yO9ehWR8qvfLaBefJNFxaVw=="],
|
||||
|
||||
"@ai-sdk/google": ["@ai-sdk/google@1.2.18", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-8B70+i+uB12Ae6Sn6B9Oc6W0W/XorGgc88Nx0pyUrcxFOdytHBaAVhTPqYsO3LLClfjYN8pQ9GMxd5cpGEnUcA=="],
|
||||
|
||||
"@ai-sdk/openai": ["@ai-sdk/openai@1.3.22", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-QwA+2EkG0QyjVR+7h6FE7iOu2ivNqAVMm9UJZkVxxTk5OIq5fFJDTEI/zICEMuHImTTXR2JjsL6EirJ28Jc4cw=="],
|
||||
|
||||
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@0.2.14", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-icjObfMCHKSIbywijaoLdZ1nSnuRnWgMEMLgwoxPJgxsUHMx0aVORnsLUid4SPtdhHI3X2masrt6iaEQLvOSFw=="],
|
||||
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
|
||||
|
||||
"@ai-sdk/react": ["@ai-sdk/react@1.2.12", "", { "dependencies": { "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/ui-utils": "1.2.11", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["zod"] }, "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g=="],
|
||||
|
||||
"@ai-sdk/ui-utils": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="],
|
||||
|
||||
"@ai-sdk/xai": ["@ai-sdk/xai@1.2.16", "", { "dependencies": { "@ai-sdk/openai-compatible": "0.2.14", "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-UOZT8td9PWwMi2dF9a0U44t/Oltmf6QmIJdSvrOcLG4mvpRc1UJn6YJaR0HtXs3YnW6SvY1zRdIDrW4GFpv4NA=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.2.14", "", { "dependencies": { "bun-types": "1.2.14" } }, "sha512-VsFZKs8oKHzI7zwvECiAJ5oSorWndIWEVhfbYqZd4HI/45kzW7PN2Rr5biAzvGvRuNmYLSANY+H59ubHq8xw7Q=="],
|
||||
|
||||
"@types/diff-match-patch": ["@types/diff-match-patch@1.0.36", "", {}, "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/node": ["@types/node@22.15.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ=="],
|
||||
|
||||
"ai": ["ai@4.3.16", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-Kuh4Ub28ucMRWeiUUWMHsT9Wcbr4H3kLIO72RZZElSDxSu7vpetRvxIUDUaW6QtaIeixIpm7OXtNnZPf82EzwA=="],
|
||||
|
||||
"chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="],
|
||||
|
||||
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||
|
||||
"jsondiffpatch": ["jsondiffpatch@0.6.0", "", { "dependencies": { "@types/diff-match-patch": "^1.0.36", "chalk": "^5.3.0", "diff-match-patch": "^1.0.5" }, "bin": { "jsondiffpatch": "bin/jsondiffpatch.js" } }, "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
|
||||
|
||||
"secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="],
|
||||
|
||||
"swr": ["swr@2.3.3", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A=="],
|
||||
|
||||
"throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="],
|
||||
|
||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
|
||||
|
||||
"zod": ["zod@3.25.30", "", {}, "sha512-VolhdEtu6TJr/fzGuHA/SZ5ixvXqA6ADOG9VRcQ3rdOKmF5hkmcJbyaQjUH5BgmpA9gej++zYRX7zjSmdReIwA=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
|
||||
}
|
||||
}
|
||||
BIN
demo.png
Normal file
BIN
demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
35
package.json
Normal file
35
package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "anyclaude",
|
||||
"version": "1.0.4",
|
||||
"author": {
|
||||
"name": "coder",
|
||||
"email": "support@coder.com",
|
||||
"url": "https://coder.com"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/coder/anyclaude"
|
||||
},
|
||||
"bin": {
|
||||
"anyclaude": "./dist/main.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-sdk/anthropic": "^1.2.12",
|
||||
"@ai-sdk/azure": "^1.3.23",
|
||||
"@ai-sdk/google": "^1.2.18",
|
||||
"@ai-sdk/openai": "^1.3.22",
|
||||
"@ai-sdk/xai": "^1.2.16",
|
||||
"@types/bun": "latest",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"ai": "^4.3.16",
|
||||
"json-schema": "^0.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"description": "Run Claude Code with OpenAI, Google, xAI, and others.",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "bun build --target node --outfile dist/main.js ./src/main.ts --format cjs && sed -i '0,/^/s//#!\\/usr\\/bin\\/env node\\n/' ./dist/main.js"
|
||||
}
|
||||
}
|
||||
212
src/anthropic-api-types.ts
Normal file
212
src/anthropic-api-types.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import type { JSONSchema7 } from "@ai-sdk/provider";
|
||||
import type { FinishReason } from "ai";
|
||||
|
||||
export type AnthropicMessagesPrompt = {
|
||||
system: Array<AnthropicTextContent> | undefined;
|
||||
messages: AnthropicMessage[];
|
||||
};
|
||||
|
||||
export type AnthropicMessage = AnthropicUserMessage | AnthropicAssistantMessage;
|
||||
|
||||
export type AnthropicCacheControl = { type: "ephemeral" };
|
||||
|
||||
export interface AnthropicUserMessage {
|
||||
role: "user";
|
||||
content: Array<
|
||||
| AnthropicTextContent
|
||||
| AnthropicImageContent
|
||||
| AnthropicDocumentContent
|
||||
| AnthropicToolResultContent
|
||||
>;
|
||||
}
|
||||
|
||||
export interface AnthropicAssistantMessage {
|
||||
role: "assistant";
|
||||
content: Array<
|
||||
| AnthropicTextContent
|
||||
| AnthropicThinkingContent
|
||||
| AnthropicRedactedThinkingContent
|
||||
| AnthropicToolCallContent
|
||||
>;
|
||||
}
|
||||
|
||||
export interface AnthropicTextContent {
|
||||
type: "text";
|
||||
text: string;
|
||||
cache_control: AnthropicCacheControl | undefined;
|
||||
}
|
||||
|
||||
export interface AnthropicThinkingContent {
|
||||
type: "thinking";
|
||||
thinking: string;
|
||||
signature: string;
|
||||
cache_control: AnthropicCacheControl | undefined;
|
||||
}
|
||||
|
||||
export interface AnthropicRedactedThinkingContent {
|
||||
type: "redacted_thinking";
|
||||
data: string;
|
||||
cache_control: AnthropicCacheControl | undefined;
|
||||
}
|
||||
|
||||
type AnthropicContentSource =
|
||||
| {
|
||||
type: "base64";
|
||||
media_type: string;
|
||||
data: string;
|
||||
}
|
||||
| {
|
||||
type: "url";
|
||||
url: string;
|
||||
};
|
||||
|
||||
export interface AnthropicImageContent {
|
||||
type: "image";
|
||||
source: AnthropicContentSource;
|
||||
cache_control: AnthropicCacheControl | undefined;
|
||||
}
|
||||
|
||||
export interface AnthropicDocumentContent {
|
||||
type: "document";
|
||||
source: AnthropicContentSource;
|
||||
cache_control: AnthropicCacheControl | undefined;
|
||||
}
|
||||
|
||||
export interface AnthropicToolCallContent {
|
||||
type: "tool_use";
|
||||
id: string;
|
||||
name: string;
|
||||
input: unknown;
|
||||
cache_control: AnthropicCacheControl | undefined;
|
||||
}
|
||||
|
||||
export interface AnthropicToolResultContent {
|
||||
type: "tool_result";
|
||||
tool_use_id: string;
|
||||
content: string | Array<AnthropicTextContent | AnthropicImageContent>;
|
||||
is_error: boolean | undefined;
|
||||
cache_control: AnthropicCacheControl | undefined;
|
||||
}
|
||||
|
||||
export type AnthropicTool =
|
||||
| {
|
||||
name: string;
|
||||
description: string | undefined;
|
||||
input_schema: JSONSchema7;
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
type: "computer_20250124" | "computer_20241022";
|
||||
display_width_px: number;
|
||||
display_height_px: number;
|
||||
display_number: number;
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
type: "text_editor_20250124" | "text_editor_20241022";
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
type: "bash_20250124" | "bash_20241022";
|
||||
};
|
||||
|
||||
export type AnthropicToolChoice =
|
||||
| { type: "auto" | "any" }
|
||||
| { type: "tool"; name: string };
|
||||
|
||||
export type AnthropicStreamUsage = {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
};
|
||||
|
||||
export type AnthropicStreamChunk =
|
||||
| {
|
||||
type: "message_start";
|
||||
message: AnthropicAssistantMessage & {
|
||||
id: string;
|
||||
model: string;
|
||||
stop_reason: string | null;
|
||||
stop_sequence: string | null;
|
||||
usage: AnthropicStreamUsage;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "content_block_start";
|
||||
index: number;
|
||||
content_block:
|
||||
| {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: "tool_use";
|
||||
id: string;
|
||||
name: string;
|
||||
input: any;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "content_block_delta";
|
||||
index: number;
|
||||
delta:
|
||||
| {
|
||||
type: "text_delta";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: "input_json_delta";
|
||||
partial_json: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "content_block_stop";
|
||||
index: number;
|
||||
}
|
||||
| {
|
||||
type: "message_delta";
|
||||
delta: {
|
||||
stop_reason: string;
|
||||
stop_sequence: string | null;
|
||||
};
|
||||
usage: AnthropicStreamUsage;
|
||||
}
|
||||
| {
|
||||
type: "message_stop";
|
||||
}
|
||||
| {
|
||||
type: "error";
|
||||
error: {
|
||||
type: "api_error";
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AnthropicMessagesRequest = {
|
||||
model: string;
|
||||
max_tokens: number;
|
||||
messages: AnthropicMessage[];
|
||||
temperature: number;
|
||||
metadata: {
|
||||
user_id: string;
|
||||
};
|
||||
system?: Array<AnthropicTextContent>;
|
||||
tools?: Array<{
|
||||
name: string;
|
||||
description: string | undefined;
|
||||
input_schema: JSONSchema7;
|
||||
}>;
|
||||
stream: boolean;
|
||||
};
|
||||
|
||||
export function mapAnthropicStopReason(finishReason: FinishReason): string {
|
||||
switch (finishReason) {
|
||||
case "stop":
|
||||
return "end_turn";
|
||||
case "tool-calls":
|
||||
return "tool_use";
|
||||
case "length":
|
||||
return "max_tokens";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
235
src/anthropic-proxy.ts
Normal file
235
src/anthropic-proxy.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import type { ProviderV1 } from "@ai-sdk/provider";
|
||||
import { jsonSchema, streamText, type Tool } from "ai";
|
||||
import * as http from "http";
|
||||
import * as https from "https";
|
||||
import type { AnthropicMessagesRequest } from "./anthropic-api-types";
|
||||
import { mapAnthropicStopReason } from "./anthropic-api-types";
|
||||
import {
|
||||
convertFromAnthropicMessages,
|
||||
convertToAnthropicMessagesPrompt,
|
||||
} from "./convert-anthropic-messages";
|
||||
import { convertToAnthropicStream } from "./convert-to-anthropic-stream";
|
||||
import { convertToLanguageModelMessage } from "./convert-to-language-model-prompt";
|
||||
import { providerizeSchema } from "./json-schema";
|
||||
|
||||
export type CreateAnthropicProxyOptions = {
|
||||
providers: Record<string, ProviderV1>;
|
||||
port?: number;
|
||||
};
|
||||
|
||||
// createAnthropicProxy creates a proxy server that accepts
|
||||
// Anthropic Message API requests and proxies them through
|
||||
// the appropriate provider - converting the results back
|
||||
// to the Anthropic Message API format.
|
||||
export const createAnthropicProxy = ({
|
||||
port,
|
||||
providers,
|
||||
}: CreateAnthropicProxyOptions): string => {
|
||||
const proxy = http
|
||||
.createServer((req, res) => {
|
||||
if (!req.url) {
|
||||
res.writeHead(400, {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: "No URL provided",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const proxyToAnthropic = (body?: AnthropicMessagesRequest) => {
|
||||
delete req.headers["host"];
|
||||
|
||||
const proxy = https.request(
|
||||
{
|
||||
host: "api.anthropic.com",
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
},
|
||||
(proxiedRes) => {
|
||||
res.writeHead(proxiedRes.statusCode ?? 500, proxiedRes.headers);
|
||||
proxiedRes.pipe(res, {
|
||||
end: true,
|
||||
});
|
||||
}
|
||||
);
|
||||
if (body) {
|
||||
proxy.end(JSON.stringify(body));
|
||||
} else {
|
||||
req.pipe(proxy, {
|
||||
end: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!req.url.startsWith("/v1/messages")) {
|
||||
proxyToAnthropic();
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const body = await new Promise<AnthropicMessagesRequest>(
|
||||
(resolve, reject) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
resolve(JSON.parse(body));
|
||||
});
|
||||
req.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const modelParts = body.model.split("/");
|
||||
|
||||
let providerName: string;
|
||||
let model: string;
|
||||
if (modelParts.length === 1) {
|
||||
// If the user has the Anthropic provider configured,
|
||||
// proxy all requests through there instead.
|
||||
if (providers.anthropic) {
|
||||
providerName = "anthropic";
|
||||
model = modelParts[0]!;
|
||||
} else {
|
||||
// If they don't have it configured, just use
|
||||
// the normal Anthropic API.
|
||||
proxyToAnthropic(body);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
providerName = modelParts[0]!;
|
||||
model = modelParts[1]!;
|
||||
}
|
||||
|
||||
const provider = providers[providerName];
|
||||
if (!provider) {
|
||||
throw new Error(`Unknown provider: ${providerName}`);
|
||||
}
|
||||
|
||||
const coreMessages = convertFromAnthropicMessages(body.messages);
|
||||
let system: string | undefined;
|
||||
if (body.system && body.system.length > 0) {
|
||||
system = body.system.map((s) => s.text).join("\n");
|
||||
}
|
||||
|
||||
const tools = body.tools?.reduce((acc, tool) => {
|
||||
acc[tool.name] = {
|
||||
description: tool.name,
|
||||
parameters: jsonSchema(
|
||||
providerizeSchema(providerName, tool.input_schema)
|
||||
),
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<string, Tool>);
|
||||
|
||||
const stream = streamText({
|
||||
model: provider.languageModel(model),
|
||||
system,
|
||||
tools,
|
||||
messages: coreMessages,
|
||||
maxTokens: body.max_tokens,
|
||||
temperature: body.temperature,
|
||||
|
||||
onFinish: ({ response, usage, finishReason }) => {
|
||||
// If the body is already being streamed,
|
||||
// we don't need to do any conversion here.
|
||||
if (body.stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
// There should only be one message.
|
||||
const message = response.messages[0];
|
||||
if (!message) {
|
||||
throw new Error("No message found");
|
||||
}
|
||||
|
||||
const prompt = convertToAnthropicMessagesPrompt({
|
||||
prompt: [convertToLanguageModelMessage(message, {})],
|
||||
sendReasoning: true,
|
||||
warnings: [],
|
||||
});
|
||||
const promptMessage = prompt.prompt.messages[0];
|
||||
if (!promptMessage) {
|
||||
throw new Error("No prompt message found");
|
||||
}
|
||||
|
||||
res.writeHead(200, { "Content-Type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
id: message.id,
|
||||
type: "message",
|
||||
role: promptMessage.role,
|
||||
content: promptMessage.content,
|
||||
model: body.model,
|
||||
stop_reason: mapAnthropicStopReason(finishReason),
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: usage.promptTokens,
|
||||
output_tokens: usage.completionTokens,
|
||||
},
|
||||
})
|
||||
);
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
res
|
||||
.writeHead(400, {
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
.end(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: error instanceof Error ? error.message : error,
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (!body.stream) {
|
||||
await stream.consumeStream();
|
||||
return;
|
||||
}
|
||||
|
||||
res.on("error", () => {
|
||||
// In NodeJS, this needs to be handled.
|
||||
// We already send the error to the client.
|
||||
});
|
||||
|
||||
await convertToAnthropicStream(stream.fullStream).pipeTo(
|
||||
new WritableStream({
|
||||
write(chunk) {
|
||||
res.write(
|
||||
`event: ${chunk.type}\ndata: ${JSON.stringify(chunk)}\n\n`
|
||||
);
|
||||
},
|
||||
close() {
|
||||
res.end();
|
||||
},
|
||||
})
|
||||
);
|
||||
})().catch((err) => {
|
||||
res.writeHead(500, {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: "Internal server error: " + err.message,
|
||||
})
|
||||
);
|
||||
});
|
||||
})
|
||||
.listen(port ?? 0);
|
||||
|
||||
const address = proxy.address();
|
||||
if (!address) {
|
||||
throw new Error("Failed to get proxy address");
|
||||
}
|
||||
if (typeof address === "string") {
|
||||
return address;
|
||||
}
|
||||
return `http://localhost:${address.port}`;
|
||||
};
|
||||
9
src/claude-config.ts
Normal file
9
src/claude-config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { readFileSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import path from "path";
|
||||
|
||||
export const readClaudeCodeAPIKey = (): string => {
|
||||
const data = readFileSync(path.join(homedir(), ".claude.json"), "utf8");
|
||||
const config = JSON.parse(data);
|
||||
return config.primaryApiKey;
|
||||
};
|
||||
461
src/convert-anthropic-messages.ts
Normal file
461
src/convert-anthropic-messages.ts
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
import {
|
||||
type LanguageModelV1CallWarning,
|
||||
type LanguageModelV1Message,
|
||||
type LanguageModelV1Prompt,
|
||||
type LanguageModelV1ProviderMetadata,
|
||||
UnsupportedFunctionalityError,
|
||||
} from "@ai-sdk/provider";
|
||||
import { convertUint8ArrayToBase64 } from "@ai-sdk/provider-utils";
|
||||
import type {
|
||||
AnthropicAssistantMessage,
|
||||
AnthropicCacheControl,
|
||||
AnthropicMessage,
|
||||
AnthropicMessagesPrompt,
|
||||
AnthropicUserMessage,
|
||||
} from "./anthropic-api-types";
|
||||
import type { CoreMessage, FilePart, TextPart, ToolCallPart } from "ai";
|
||||
import type { ReasoningUIPart } from "@ai-sdk/ui-utils";
|
||||
|
||||
export function convertToAnthropicMessagesPrompt({
|
||||
prompt,
|
||||
sendReasoning,
|
||||
warnings,
|
||||
}: {
|
||||
prompt: LanguageModelV1Prompt;
|
||||
sendReasoning: boolean;
|
||||
warnings: LanguageModelV1CallWarning[];
|
||||
}): {
|
||||
prompt: AnthropicMessagesPrompt;
|
||||
betas: Set<string>;
|
||||
} {
|
||||
const betas = new Set<string>();
|
||||
const blocks = groupIntoBlocks(prompt);
|
||||
|
||||
let system: AnthropicMessagesPrompt["system"] = undefined;
|
||||
const messages: AnthropicMessagesPrompt["messages"] = [];
|
||||
|
||||
function getCacheControl(
|
||||
providerMetadata: LanguageModelV1ProviderMetadata | undefined
|
||||
): AnthropicCacheControl | undefined {
|
||||
const anthropic = providerMetadata?.anthropic;
|
||||
|
||||
// allow both cacheControl and cache_control:
|
||||
const cacheControlValue =
|
||||
anthropic?.cacheControl ?? anthropic?.cache_control;
|
||||
|
||||
// Pass through value assuming it is of the correct type.
|
||||
// The Anthropic API will validate the value.
|
||||
return cacheControlValue as AnthropicCacheControl | undefined;
|
||||
}
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i]!;
|
||||
const isLastBlock = i === blocks.length - 1;
|
||||
const type = block.type;
|
||||
|
||||
switch (type) {
|
||||
case "system": {
|
||||
if (system != null) {
|
||||
throw new UnsupportedFunctionalityError({
|
||||
functionality:
|
||||
"Multiple system messages that are separated by user/assistant messages",
|
||||
});
|
||||
}
|
||||
|
||||
system = block.messages.map(({ content, providerMetadata }) => ({
|
||||
type: "text",
|
||||
text: content,
|
||||
cache_control: getCacheControl(providerMetadata),
|
||||
}));
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "user": {
|
||||
// combines all user and tool messages in this block into a single message:
|
||||
const anthropicContent: AnthropicUserMessage["content"] = [];
|
||||
|
||||
for (const message of block.messages) {
|
||||
const { role, content } = message;
|
||||
switch (role) {
|
||||
case "user": {
|
||||
for (let j = 0; j < content.length; j++) {
|
||||
const part = content[j]!;
|
||||
|
||||
// cache control: first add cache control from part.
|
||||
// for the last part of a message,
|
||||
// check also if the message has cache control.
|
||||
const isLastPart = j === content.length - 1;
|
||||
|
||||
const cacheControl =
|
||||
getCacheControl(part.providerMetadata) ??
|
||||
(isLastPart
|
||||
? getCacheControl(message.providerMetadata)
|
||||
: undefined);
|
||||
|
||||
switch (part.type) {
|
||||
case "text": {
|
||||
anthropicContent.push({
|
||||
type: "text",
|
||||
text: part.text,
|
||||
cache_control: cacheControl,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "image": {
|
||||
anthropicContent.push({
|
||||
type: "image",
|
||||
source:
|
||||
part.image instanceof URL
|
||||
? {
|
||||
type: "url",
|
||||
url: part.image.toString(),
|
||||
}
|
||||
: {
|
||||
type: "base64",
|
||||
media_type: part.mimeType ?? "image/jpeg",
|
||||
data: convertUint8ArrayToBase64(part.image),
|
||||
},
|
||||
cache_control: cacheControl,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "file": {
|
||||
if (part.mimeType !== "application/pdf") {
|
||||
throw new UnsupportedFunctionalityError({
|
||||
functionality: "Non-PDF files in user messages",
|
||||
});
|
||||
}
|
||||
|
||||
betas.add("pdfs-2024-09-25");
|
||||
|
||||
anthropicContent.push({
|
||||
type: "document",
|
||||
source:
|
||||
part.data instanceof URL
|
||||
? {
|
||||
type: "url",
|
||||
url: part.data.toString(),
|
||||
}
|
||||
: {
|
||||
type: "base64",
|
||||
media_type: "application/pdf",
|
||||
data: part.data,
|
||||
},
|
||||
cache_control: cacheControl,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "tool": {
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const part = content[i]!;
|
||||
|
||||
// cache control: first add cache control from part.
|
||||
// for the last part of a message,
|
||||
// check also if the message has cache control.
|
||||
const isLastPart = i === content.length - 1;
|
||||
|
||||
const cacheControl =
|
||||
getCacheControl(part.providerMetadata) ??
|
||||
(isLastPart
|
||||
? getCacheControl(message.providerMetadata)
|
||||
: undefined);
|
||||
|
||||
const toolResultContent =
|
||||
part.content != null
|
||||
? part.content.map((part) => {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: part.text,
|
||||
cache_control: undefined,
|
||||
};
|
||||
case "image":
|
||||
return {
|
||||
type: "image" as const,
|
||||
source: {
|
||||
type: "base64" as const,
|
||||
media_type: part.mimeType ?? "image/jpeg",
|
||||
data: part.data,
|
||||
},
|
||||
cache_control: undefined,
|
||||
};
|
||||
}
|
||||
})
|
||||
: JSON.stringify(part.result);
|
||||
|
||||
anthropicContent.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: part.toolCallId,
|
||||
content: toolResultContent,
|
||||
is_error: part.isError,
|
||||
cache_control: cacheControl,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const _exhaustiveCheck: never = role;
|
||||
throw new Error(`Unsupported role: ${_exhaustiveCheck}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({ role: "user", content: anthropicContent });
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "assistant": {
|
||||
// combines multiple assistant messages in this block into a single message:
|
||||
const anthropicContent: AnthropicAssistantMessage["content"] = [];
|
||||
|
||||
for (let j = 0; j < block.messages.length; j++) {
|
||||
const message = block.messages[j]!;
|
||||
const isLastMessage = j === block.messages.length - 1;
|
||||
const { content } = message;
|
||||
|
||||
for (let k = 0; k < content.length; k++) {
|
||||
const part = content[k]!;
|
||||
const isLastContentPart = k === content.length - 1;
|
||||
|
||||
// cache control: first add cache control from part.
|
||||
// for the last part of a message,
|
||||
// check also if the message has cache control.
|
||||
const cacheControl =
|
||||
getCacheControl(part.providerMetadata) ??
|
||||
(isLastContentPart
|
||||
? getCacheControl(message.providerMetadata)
|
||||
: undefined);
|
||||
|
||||
switch (part.type) {
|
||||
case "text": {
|
||||
anthropicContent.push({
|
||||
type: "text",
|
||||
text:
|
||||
// trim the last text part if it's the last message in the block
|
||||
// because Anthropic does not allow trailing whitespace
|
||||
// in pre-filled assistant responses
|
||||
isLastBlock && isLastMessage && isLastContentPart
|
||||
? part.text.trim()
|
||||
: part.text,
|
||||
|
||||
cache_control: cacheControl,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "reasoning": {
|
||||
if (sendReasoning) {
|
||||
anthropicContent.push({
|
||||
type: "thinking",
|
||||
thinking: part.text,
|
||||
signature: part.signature!,
|
||||
cache_control: cacheControl,
|
||||
});
|
||||
} else {
|
||||
warnings.push({
|
||||
type: "other",
|
||||
message:
|
||||
"sending reasoning content is disabled for this model",
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "redacted-reasoning": {
|
||||
anthropicContent.push({
|
||||
type: "redacted_thinking",
|
||||
data: part.data,
|
||||
cache_control: cacheControl,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool-call": {
|
||||
anthropicContent.push({
|
||||
type: "tool_use",
|
||||
id: part.toolCallId,
|
||||
name: part.toolName,
|
||||
input: part.args,
|
||||
cache_control: cacheControl,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({ role: "assistant", content: anthropicContent });
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
const _exhaustiveCheck: never = type;
|
||||
throw new Error(`Unsupported type: ${_exhaustiveCheck}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
prompt: { system, messages },
|
||||
betas,
|
||||
};
|
||||
}
|
||||
|
||||
type SystemBlock = {
|
||||
type: "system";
|
||||
messages: Array<LanguageModelV1Message & { role: "system" }>;
|
||||
};
|
||||
type AssistantBlock = {
|
||||
type: "assistant";
|
||||
messages: Array<LanguageModelV1Message & { role: "assistant" }>;
|
||||
};
|
||||
type UserBlock = {
|
||||
type: "user";
|
||||
messages: Array<LanguageModelV1Message & { role: "user" | "tool" }>;
|
||||
};
|
||||
|
||||
function groupIntoBlocks(
|
||||
prompt: LanguageModelV1Prompt
|
||||
): Array<SystemBlock | AssistantBlock | UserBlock> {
|
||||
const blocks: Array<SystemBlock | AssistantBlock | UserBlock> = [];
|
||||
let currentBlock: SystemBlock | AssistantBlock | UserBlock | undefined =
|
||||
undefined;
|
||||
|
||||
for (const message of prompt) {
|
||||
const { role } = message;
|
||||
switch (role) {
|
||||
case "system": {
|
||||
if (currentBlock?.type !== "system") {
|
||||
currentBlock = { type: "system", messages: [] };
|
||||
blocks.push(currentBlock);
|
||||
}
|
||||
|
||||
currentBlock.messages.push(message);
|
||||
break;
|
||||
}
|
||||
case "assistant": {
|
||||
if (currentBlock?.type !== "assistant") {
|
||||
currentBlock = { type: "assistant", messages: [] };
|
||||
blocks.push(currentBlock);
|
||||
}
|
||||
|
||||
currentBlock.messages.push(message);
|
||||
break;
|
||||
}
|
||||
case "user": {
|
||||
if (currentBlock?.type !== "user") {
|
||||
currentBlock = { type: "user", messages: [] };
|
||||
blocks.push(currentBlock);
|
||||
}
|
||||
|
||||
currentBlock.messages.push(message);
|
||||
break;
|
||||
}
|
||||
case "tool": {
|
||||
if (currentBlock?.type !== "user") {
|
||||
currentBlock = { type: "user", messages: [] };
|
||||
blocks.push(currentBlock);
|
||||
}
|
||||
|
||||
currentBlock.messages.push(message);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const _exhaustiveCheck: never = role;
|
||||
throw new Error(`Unsupported role: ${_exhaustiveCheck}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export function convertFromAnthropicMessages(
|
||||
messages: ReadonlyArray<AnthropicMessage>
|
||||
) {
|
||||
const result: CoreMessage[] = [];
|
||||
let toolCalls: Record<string, ToolCallPart> = {};
|
||||
|
||||
for (const message of messages) {
|
||||
const messageContent: (
|
||||
| TextPart
|
||||
| FilePart
|
||||
| ReasoningUIPart
|
||||
| ToolCallPart
|
||||
)[] = [];
|
||||
|
||||
if (typeof message.content !== "string") {
|
||||
message.content.forEach((content) => {
|
||||
switch (content.type) {
|
||||
case "text": {
|
||||
messageContent.push({
|
||||
type: "text",
|
||||
text: content.text,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "tool_use": {
|
||||
messageContent.push({
|
||||
type: "tool-call",
|
||||
args: content.input,
|
||||
toolCallId: content.id,
|
||||
toolName: content.name,
|
||||
});
|
||||
toolCalls[content.id] = {
|
||||
type: "tool-call",
|
||||
args: content.input,
|
||||
toolCallId: content.id,
|
||||
toolName: content.name,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "tool_result": {
|
||||
const toolCall = toolCalls[content.tool_use_id];
|
||||
if (!toolCall) {
|
||||
throw new Error("Tool call not found");
|
||||
}
|
||||
result.push({
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
result: content.content,
|
||||
toolCallId: content.tool_use_id,
|
||||
toolName: toolCall.toolName,
|
||||
type: "tool-result",
|
||||
},
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
messageContent.push({
|
||||
type: "text",
|
||||
text: message.content as string,
|
||||
});
|
||||
}
|
||||
|
||||
if (messageContent.length > 0) {
|
||||
result.push({
|
||||
role: message.role,
|
||||
content: messageContent,
|
||||
} as CoreMessage);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
121
src/convert-to-anthropic-stream.ts
Normal file
121
src/convert-to-anthropic-stream.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import type { Tool } from "ai";
|
||||
import type { TextStreamPart } from "ai";
|
||||
import {
|
||||
mapAnthropicStopReason,
|
||||
type AnthropicStreamChunk,
|
||||
} from "./anthropic-api-types";
|
||||
|
||||
export function convertToAnthropicStream(
|
||||
stream: ReadableStream<TextStreamPart<Record<string, Tool>>>
|
||||
): ReadableStream<AnthropicStreamChunk> {
|
||||
const transform = new TransformStream<
|
||||
TextStreamPart<Record<string, Tool>>,
|
||||
AnthropicStreamChunk
|
||||
>({
|
||||
transform(chunk, controller) {
|
||||
let index = 0;
|
||||
|
||||
switch (chunk.type) {
|
||||
case "step-start":
|
||||
controller.enqueue({
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: chunk.messageId,
|
||||
role: "assistant",
|
||||
content: [],
|
||||
model: "claude-4-sonnet-20250514",
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "step-finish":
|
||||
controller.enqueue({
|
||||
type: "message_delta",
|
||||
delta: {
|
||||
stop_reason: mapAnthropicStopReason(chunk.finishReason),
|
||||
stop_sequence: null,
|
||||
},
|
||||
usage: {
|
||||
input_tokens: chunk.usage.promptTokens,
|
||||
output_tokens: chunk.usage.completionTokens,
|
||||
},
|
||||
});
|
||||
index++;
|
||||
break;
|
||||
case "finish":
|
||||
controller.enqueue({
|
||||
type: "message_stop",
|
||||
});
|
||||
break;
|
||||
case "text-delta":
|
||||
controller.enqueue({
|
||||
type: "content_block_delta",
|
||||
index: index,
|
||||
delta: {
|
||||
type: "text_delta",
|
||||
text: chunk.textDelta,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "tool-call-streaming-start":
|
||||
controller.enqueue({
|
||||
type: "content_block_start",
|
||||
index: index,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: chunk.toolCallId,
|
||||
name: chunk.toolName,
|
||||
input: {},
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "tool-call-delta":
|
||||
controller.enqueue({
|
||||
type: "content_block_delta",
|
||||
index: index,
|
||||
delta: {
|
||||
type: "input_json_delta",
|
||||
partial_json: chunk.argsTextDelta,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "tool-call":
|
||||
controller.enqueue({
|
||||
type: "content_block_start",
|
||||
index: index,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: chunk.toolCallId,
|
||||
name: chunk.toolName,
|
||||
input: chunk.args,
|
||||
},
|
||||
});
|
||||
index++;
|
||||
break;
|
||||
case "error":
|
||||
controller.enqueue({
|
||||
type: "error",
|
||||
error: {
|
||||
type: "api_error",
|
||||
message:
|
||||
chunk.error instanceof Error
|
||||
? chunk.error.message
|
||||
: chunk.error as string,
|
||||
},
|
||||
});
|
||||
break;
|
||||
default:
|
||||
controller.error(new Error(`Unhandled chunk type: ${chunk.type}`));
|
||||
}
|
||||
},
|
||||
});
|
||||
stream.pipeTo(transform.writable).catch((err) => {
|
||||
console.log("WE GOT AN ERROR");
|
||||
});
|
||||
return transform.readable;
|
||||
}
|
||||
296
src/convert-to-language-model-prompt.ts
Normal file
296
src/convert-to-language-model-prompt.ts
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import type {
|
||||
LanguageModelV1FilePart,
|
||||
LanguageModelV1ImagePart,
|
||||
LanguageModelV1Message,
|
||||
LanguageModelV1TextPart,
|
||||
} from "@ai-sdk/provider";
|
||||
import {
|
||||
InvalidMessageRoleError,
|
||||
type CoreMessage,
|
||||
type DataContent,
|
||||
type FilePart,
|
||||
type ImagePart,
|
||||
type TextPart,
|
||||
} from "ai";
|
||||
import {
|
||||
convertDataContentToBase64String,
|
||||
convertDataContentToUint8Array,
|
||||
} from "./data-content";
|
||||
import { detectMimeType, imageMimeTypeSignatures } from "./detect-mimetype";
|
||||
import { splitDataUrl } from "./split-data-url";
|
||||
|
||||
/**
|
||||
* Convert a CoreMessage to a LanguageModelV1Message.
|
||||
*
|
||||
* @param message The CoreMessage to convert.
|
||||
* @param downloadedAssets A map of URLs to their downloaded data. Only
|
||||
* available if the model does not support URLs, null otherwise.
|
||||
*/
|
||||
export function convertToLanguageModelMessage(
|
||||
message: CoreMessage,
|
||||
downloadedAssets: Record<
|
||||
string,
|
||||
{ mimeType: string | undefined; data: Uint8Array }
|
||||
>
|
||||
): LanguageModelV1Message {
|
||||
const role = message.role;
|
||||
switch (role) {
|
||||
case "system": {
|
||||
return {
|
||||
role: "system",
|
||||
content: message.content,
|
||||
providerMetadata:
|
||||
message.providerOptions ?? message.experimental_providerMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
case "user": {
|
||||
if (typeof message.content === "string") {
|
||||
return {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: message.content }],
|
||||
providerMetadata:
|
||||
message.providerOptions ?? message.experimental_providerMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: "user",
|
||||
content: message.content
|
||||
.map((part) => convertPartToLanguageModelPart(part, downloadedAssets))
|
||||
// remove empty text parts:
|
||||
.filter((part) => part.type !== "text" || part.text !== ""),
|
||||
providerMetadata:
|
||||
message.providerOptions ?? message.experimental_providerMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
case "assistant": {
|
||||
if (typeof message.content === "string") {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: message.content }],
|
||||
providerMetadata:
|
||||
message.providerOptions ?? message.experimental_providerMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: "assistant",
|
||||
content: message.content
|
||||
.filter(
|
||||
// remove empty text parts:
|
||||
(part) => part.type !== "text" || part.text !== ""
|
||||
)
|
||||
.map((part) => {
|
||||
const providerOptions =
|
||||
part.providerOptions ?? part.experimental_providerMetadata;
|
||||
|
||||
switch (part.type) {
|
||||
case "file": {
|
||||
return {
|
||||
type: "file",
|
||||
data:
|
||||
part.data instanceof URL
|
||||
? part.data
|
||||
: convertDataContentToBase64String(part.data),
|
||||
filename: part.filename,
|
||||
mimeType: part.mimeType,
|
||||
providerMetadata: providerOptions,
|
||||
};
|
||||
}
|
||||
case "reasoning": {
|
||||
return {
|
||||
type: "reasoning",
|
||||
text: part.text,
|
||||
signature: part.signature,
|
||||
providerMetadata: providerOptions,
|
||||
};
|
||||
}
|
||||
case "redacted-reasoning": {
|
||||
return {
|
||||
type: "redacted-reasoning",
|
||||
data: part.data,
|
||||
providerMetadata: providerOptions,
|
||||
};
|
||||
}
|
||||
case "text": {
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: part.text,
|
||||
providerMetadata: providerOptions,
|
||||
};
|
||||
}
|
||||
case "tool-call": {
|
||||
return {
|
||||
type: "tool-call" as const,
|
||||
toolCallId: part.toolCallId,
|
||||
toolName: part.toolName,
|
||||
args: part.args,
|
||||
providerMetadata: providerOptions,
|
||||
};
|
||||
}
|
||||
}
|
||||
}),
|
||||
providerMetadata:
|
||||
message.providerOptions ?? message.experimental_providerMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
case "tool": {
|
||||
return {
|
||||
role: "tool",
|
||||
content: message.content.map((part) => ({
|
||||
type: "tool-result",
|
||||
toolCallId: part.toolCallId,
|
||||
toolName: part.toolName,
|
||||
result: part.result,
|
||||
content: part.experimental_content,
|
||||
isError: part.isError,
|
||||
providerMetadata:
|
||||
part.providerOptions ?? part.experimental_providerMetadata,
|
||||
})),
|
||||
providerMetadata:
|
||||
message.providerOptions ?? message.experimental_providerMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
const _exhaustiveCheck: never = role;
|
||||
throw new InvalidMessageRoleError({ role: _exhaustiveCheck });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert part of a message to a LanguageModelV1Part.
|
||||
* @param part The part to convert.
|
||||
* @param downloadedAssets A map of URLs to their downloaded data. Only
|
||||
* available if the model does not support URLs, null otherwise.
|
||||
*
|
||||
* @returns The converted part.
|
||||
*/
|
||||
function convertPartToLanguageModelPart(
|
||||
part: TextPart | ImagePart | FilePart,
|
||||
downloadedAssets: Record<
|
||||
string,
|
||||
{ mimeType: string | undefined; data: Uint8Array }
|
||||
>
|
||||
):
|
||||
| LanguageModelV1TextPart
|
||||
| LanguageModelV1ImagePart
|
||||
| LanguageModelV1FilePart {
|
||||
if (part.type === "text") {
|
||||
return {
|
||||
type: "text",
|
||||
text: part.text,
|
||||
providerMetadata:
|
||||
part.providerOptions ?? part.experimental_providerMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
let mimeType: string | undefined = part.mimeType;
|
||||
let data: DataContent | URL;
|
||||
let content: URL | ArrayBuffer | string;
|
||||
let normalizedData: Uint8Array | URL;
|
||||
|
||||
const type = part.type;
|
||||
switch (type) {
|
||||
case "image":
|
||||
data = part.image;
|
||||
break;
|
||||
case "file":
|
||||
data = part.data;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported part type: ${type}`);
|
||||
}
|
||||
|
||||
// Attempt to create a URL from the data. If it fails, we can assume the data
|
||||
// is not a URL and likely some other sort of data.
|
||||
try {
|
||||
content = typeof data === "string" ? new URL(data) : (data as ArrayBuffer);
|
||||
} catch (error) {
|
||||
content = data as ArrayBuffer;
|
||||
}
|
||||
|
||||
// If we successfully created a URL, we can use that to normalize the data
|
||||
// either by passing it through or converting normalizing the base64 content
|
||||
// to a Uint8Array.
|
||||
if (content instanceof URL) {
|
||||
// If the content is a data URL, we want to convert that to a Uint8Array
|
||||
if (content.protocol === "data:") {
|
||||
const { mimeType: dataUrlMimeType, base64Content } = splitDataUrl(
|
||||
content.toString()
|
||||
);
|
||||
|
||||
if (dataUrlMimeType == null || base64Content == null) {
|
||||
throw new Error(`Invalid data URL format in part ${type}`);
|
||||
}
|
||||
|
||||
mimeType = dataUrlMimeType;
|
||||
normalizedData = convertDataContentToUint8Array(base64Content);
|
||||
} else {
|
||||
/**
|
||||
* If the content is a URL, we should first see if it was downloaded. And if not,
|
||||
* we can let the model decide if it wants to support the URL. This also allows
|
||||
* for non-HTTP URLs to be passed through (e.g. gs://).
|
||||
*/
|
||||
const downloadedFile = downloadedAssets[content.toString()];
|
||||
if (downloadedFile) {
|
||||
normalizedData = downloadedFile.data;
|
||||
mimeType ??= downloadedFile.mimeType;
|
||||
} else {
|
||||
normalizedData = content;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Since we know now the content is not a URL, we can attempt to normalize
|
||||
// the data assuming it is some sort of data.
|
||||
normalizedData = convertDataContentToUint8Array(content);
|
||||
}
|
||||
|
||||
// Now that we have the normalized data either as a URL or a Uint8Array,
|
||||
// we can create the LanguageModelV1Part.
|
||||
switch (type) {
|
||||
case "image": {
|
||||
// When possible, try to detect the mimetype automatically
|
||||
// to deal with incorrect mimetype inputs.
|
||||
// When detection fails, use provided mimetype.
|
||||
|
||||
if (normalizedData instanceof Uint8Array) {
|
||||
mimeType =
|
||||
detectMimeType({
|
||||
data: normalizedData,
|
||||
signatures: imageMimeTypeSignatures,
|
||||
}) ?? mimeType;
|
||||
}
|
||||
return {
|
||||
type: "image",
|
||||
image: normalizedData,
|
||||
mimeType,
|
||||
providerMetadata:
|
||||
part.providerOptions ?? part.experimental_providerMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
case "file": {
|
||||
// We should have a mimeType at this point, if not, throw an error.
|
||||
if (mimeType == null) {
|
||||
throw new Error(`Mime type is missing for file part`);
|
||||
}
|
||||
|
||||
return {
|
||||
type: "file",
|
||||
data:
|
||||
normalizedData instanceof Uint8Array
|
||||
? convertDataContentToBase64String(normalizedData)
|
||||
: normalizedData,
|
||||
filename: part.filename,
|
||||
mimeType,
|
||||
providerMetadata:
|
||||
part.providerOptions ?? part.experimental_providerMetadata,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
91
src/data-content.ts
Normal file
91
src/data-content.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import {
|
||||
convertBase64ToUint8Array,
|
||||
convertUint8ArrayToBase64,
|
||||
} from "@ai-sdk/provider-utils";
|
||||
import { InvalidDataContentError } from "./invalid-data-content-error";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
Data content. Can either be a base64-encoded string, a Uint8Array, an ArrayBuffer, or a Buffer.
|
||||
*/
|
||||
export type DataContent = string | Uint8Array | ArrayBuffer | Buffer;
|
||||
|
||||
/**
|
||||
@internal
|
||||
*/
|
||||
export const dataContentSchema: z.ZodType<DataContent> = z.union([
|
||||
z.string(),
|
||||
z.instanceof(Uint8Array),
|
||||
z.instanceof(ArrayBuffer),
|
||||
z.custom(
|
||||
// Buffer might not be available in some environments such as CloudFlare:
|
||||
(value: unknown): value is Buffer =>
|
||||
globalThis.Buffer?.isBuffer(value) ?? false,
|
||||
{ message: "Must be a Buffer" }
|
||||
),
|
||||
]);
|
||||
|
||||
/**
|
||||
Converts data content to a base64-encoded string.
|
||||
|
||||
@param content - Data content to convert.
|
||||
@returns Base64-encoded string.
|
||||
*/
|
||||
export function convertDataContentToBase64String(content: DataContent): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (content instanceof ArrayBuffer) {
|
||||
return convertUint8ArrayToBase64(new Uint8Array(content));
|
||||
}
|
||||
|
||||
return convertUint8ArrayToBase64(content);
|
||||
}
|
||||
|
||||
/**
|
||||
Converts data content to a Uint8Array.
|
||||
|
||||
@param content - Data content to convert.
|
||||
@returns Uint8Array.
|
||||
*/
|
||||
export function convertDataContentToUint8Array(
|
||||
content: DataContent
|
||||
): Uint8Array {
|
||||
if (content instanceof Uint8Array) {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (typeof content === "string") {
|
||||
try {
|
||||
return convertBase64ToUint8Array(content);
|
||||
} catch (error) {
|
||||
throw new InvalidDataContentError({
|
||||
message:
|
||||
"Invalid data content. Content string is not a base64-encoded media.",
|
||||
content,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (content instanceof ArrayBuffer) {
|
||||
return new Uint8Array(content);
|
||||
}
|
||||
|
||||
throw new InvalidDataContentError({ content });
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Uint8Array to a string of text.
|
||||
*
|
||||
* @param uint8Array - The Uint8Array to convert.
|
||||
* @returns The converted string.
|
||||
*/
|
||||
export function convertUint8ArrayToText(uint8Array: Uint8Array): string {
|
||||
try {
|
||||
return new TextDecoder().decode(uint8Array);
|
||||
} catch (error) {
|
||||
throw new Error("Error decoding Uint8Array to text");
|
||||
}
|
||||
}
|
||||
136
src/detect-mimetype.ts
Normal file
136
src/detect-mimetype.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { convertBase64ToUint8Array } from "@ai-sdk/provider-utils";
|
||||
|
||||
export const imageMimeTypeSignatures = [
|
||||
{
|
||||
mimeType: "image/gif" as const,
|
||||
bytesPrefix: [0x47, 0x49, 0x46],
|
||||
base64Prefix: "R0lG",
|
||||
},
|
||||
{
|
||||
mimeType: "image/png" as const,
|
||||
bytesPrefix: [0x89, 0x50, 0x4e, 0x47],
|
||||
base64Prefix: "iVBORw",
|
||||
},
|
||||
{
|
||||
mimeType: "image/jpeg" as const,
|
||||
bytesPrefix: [0xff, 0xd8],
|
||||
base64Prefix: "/9j/",
|
||||
},
|
||||
{
|
||||
mimeType: "image/webp" as const,
|
||||
bytesPrefix: [0x52, 0x49, 0x46, 0x46],
|
||||
base64Prefix: "UklGRg",
|
||||
},
|
||||
{
|
||||
mimeType: "image/bmp" as const,
|
||||
bytesPrefix: [0x42, 0x4d],
|
||||
base64Prefix: "Qk",
|
||||
},
|
||||
{
|
||||
mimeType: "image/tiff" as const,
|
||||
bytesPrefix: [0x49, 0x49, 0x2a, 0x00],
|
||||
base64Prefix: "SUkqAA",
|
||||
},
|
||||
{
|
||||
mimeType: "image/tiff" as const,
|
||||
bytesPrefix: [0x4d, 0x4d, 0x00, 0x2a],
|
||||
base64Prefix: "TU0AKg",
|
||||
},
|
||||
{
|
||||
mimeType: "image/avif" as const,
|
||||
bytesPrefix: [
|
||||
0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66,
|
||||
],
|
||||
base64Prefix: "AAAAIGZ0eXBhdmlm",
|
||||
},
|
||||
{
|
||||
mimeType: "image/heic" as const,
|
||||
bytesPrefix: [
|
||||
0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63,
|
||||
],
|
||||
base64Prefix: "AAAAIGZ0eXBoZWlj",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const audioMimeTypeSignatures = [
|
||||
{
|
||||
mimeType: "audio/mpeg" as const,
|
||||
bytesPrefix: [0xff, 0xfb],
|
||||
base64Prefix: "//s=",
|
||||
},
|
||||
{
|
||||
mimeType: "audio/wav" as const,
|
||||
bytesPrefix: [0x52, 0x49, 0x46, 0x46],
|
||||
base64Prefix: "UklGR",
|
||||
},
|
||||
{
|
||||
mimeType: "audio/ogg" as const,
|
||||
bytesPrefix: [0x4f, 0x67, 0x67, 0x53],
|
||||
base64Prefix: "T2dnUw",
|
||||
},
|
||||
{
|
||||
mimeType: "audio/flac" as const,
|
||||
bytesPrefix: [0x66, 0x4c, 0x61, 0x43],
|
||||
base64Prefix: "ZkxhQw",
|
||||
},
|
||||
{
|
||||
mimeType: "audio/aac" as const,
|
||||
bytesPrefix: [0x40, 0x15, 0x00, 0x00],
|
||||
base64Prefix: "QBUA",
|
||||
},
|
||||
{
|
||||
mimeType: "audio/mp4" as const,
|
||||
bytesPrefix: [0x66, 0x74, 0x79, 0x70],
|
||||
base64Prefix: "ZnR5cA",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const stripID3 = (data: Uint8Array | string) => {
|
||||
const bytes =
|
||||
typeof data === "string" ? convertBase64ToUint8Array(data) : data;
|
||||
const id3Size =
|
||||
((bytes[6]! & 0x7f) << 21) |
|
||||
((bytes[7]! & 0x7f) << 14) |
|
||||
((bytes[8]! & 0x7f) << 7) |
|
||||
(bytes[9]! & 0x7f);
|
||||
|
||||
// The raw MP3 starts here
|
||||
return bytes.slice(id3Size + 10);
|
||||
};
|
||||
|
||||
function stripID3TagsIfPresent(data: Uint8Array | string): Uint8Array | string {
|
||||
const hasId3 =
|
||||
(typeof data === "string" && data.startsWith("SUQz")) ||
|
||||
(typeof data !== "string" &&
|
||||
data.length > 10 &&
|
||||
data[0] === 0x49 && // 'I'
|
||||
data[1] === 0x44 && // 'D'
|
||||
data[2] === 0x33); // '3'
|
||||
|
||||
return hasId3 ? stripID3(data) : data;
|
||||
}
|
||||
|
||||
export function detectMimeType({
|
||||
data,
|
||||
signatures,
|
||||
}: {
|
||||
data: Uint8Array | string;
|
||||
signatures: typeof audioMimeTypeSignatures | typeof imageMimeTypeSignatures;
|
||||
}): (typeof signatures)[number]["mimeType"] | undefined {
|
||||
const processedData = stripID3TagsIfPresent(data);
|
||||
|
||||
for (const signature of signatures) {
|
||||
if (
|
||||
typeof processedData === "string"
|
||||
? processedData.startsWith(signature.base64Prefix)
|
||||
: processedData.length >= signature.bytesPrefix.length &&
|
||||
signature.bytesPrefix.every(
|
||||
(byte, index) => processedData[index] === byte
|
||||
)
|
||||
) {
|
||||
return signature.mimeType;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
29
src/invalid-data-content-error.ts
Normal file
29
src/invalid-data-content-error.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { AISDKError } from "@ai-sdk/provider";
|
||||
|
||||
const name = "AI_InvalidDataContentError";
|
||||
const marker = `vercel.ai.error.${name}`;
|
||||
const symbol = Symbol.for(marker);
|
||||
|
||||
export class InvalidDataContentError extends AISDKError {
|
||||
private readonly [symbol] = true; // used in isInstance
|
||||
|
||||
readonly content: unknown;
|
||||
|
||||
constructor({
|
||||
content,
|
||||
cause,
|
||||
message = `Invalid data content. Expected a base64 string, Uint8Array, ArrayBuffer, or Buffer, but got ${typeof content}.`,
|
||||
}: {
|
||||
content: unknown;
|
||||
cause?: unknown;
|
||||
message?: string;
|
||||
}) {
|
||||
super({ name, message, cause });
|
||||
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
static isInstance(error: unknown): error is InvalidDataContentError {
|
||||
return AISDKError.hasMarker(error, marker);
|
||||
}
|
||||
}
|
||||
75
src/json-schema.ts
Normal file
75
src/json-schema.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import type { JSONSchema7 } from "json-schema";
|
||||
|
||||
export function providerizeSchema(
|
||||
provider: string,
|
||||
schema: JSONSchema7
|
||||
): JSONSchema7 {
|
||||
// Handle primitive types or schemas without properties
|
||||
if (
|
||||
!schema ||
|
||||
typeof schema !== "object" ||
|
||||
schema.type !== "object" ||
|
||||
!schema.properties
|
||||
) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const processedProperties: Record<string, JSONSchema7> = {};
|
||||
|
||||
// Recursively process each property
|
||||
for (const [key, property] of Object.entries(schema.properties)) {
|
||||
if (typeof property === "object" && property !== null) {
|
||||
let processedProperty = property as JSONSchema7;
|
||||
|
||||
// Remove uri format for OpenAI
|
||||
if (provider === "openai" && processedProperty.format === "uri") {
|
||||
processedProperty = { ...processedProperty };
|
||||
delete processedProperty.format;
|
||||
}
|
||||
|
||||
if (processedProperty.type === "object") {
|
||||
// Recursively process nested objects
|
||||
processedProperties[key] = providerizeSchema(
|
||||
provider,
|
||||
processedProperty
|
||||
);
|
||||
} else if (
|
||||
processedProperty.type === "array" &&
|
||||
processedProperty.items
|
||||
) {
|
||||
// Handle arrays with object items
|
||||
const items = processedProperty.items;
|
||||
if (
|
||||
typeof items === "object" &&
|
||||
!Array.isArray(items) &&
|
||||
items.type === "object"
|
||||
) {
|
||||
processedProperties[key] = {
|
||||
...processedProperty,
|
||||
items: providerizeSchema(provider, items as JSONSchema7),
|
||||
};
|
||||
} else {
|
||||
processedProperties[key] = processedProperty;
|
||||
}
|
||||
} else {
|
||||
processedProperties[key] = processedProperty;
|
||||
}
|
||||
} else {
|
||||
// Handle boolean properties (true/false schemas)
|
||||
processedProperties[key] = property as unknown as JSONSchema7;
|
||||
}
|
||||
}
|
||||
|
||||
const result: JSONSchema7 = {
|
||||
...schema,
|
||||
properties: processedProperties,
|
||||
};
|
||||
|
||||
// Only add required properties for OpenAI
|
||||
if (provider === "openai") {
|
||||
result.required = Object.keys(schema.properties);
|
||||
result.additionalProperties = false;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
69
src/main.ts
Normal file
69
src/main.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// This is just intended to execute Claude Code while setting up a proxy for tokens.
|
||||
|
||||
import { createAnthropic } from "@ai-sdk/anthropic";
|
||||
import { createAzure } from "@ai-sdk/azure";
|
||||
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { createXai } from "@ai-sdk/xai";
|
||||
import { spawn } from "child_process";
|
||||
import {
|
||||
createAnthropicProxy,
|
||||
type CreateAnthropicProxyOptions,
|
||||
} from "./anthropic-proxy";
|
||||
|
||||
// providers are supported providers to proxy requests by name.
|
||||
// Model names are split when requested by `/`. The provider
|
||||
// name is the first part, and the rest is the model name.
|
||||
const providers: CreateAnthropicProxyOptions["providers"] = {
|
||||
openai: createOpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
baseURL: process.env.OPENAI_API_URL,
|
||||
}),
|
||||
azure: createAzure({
|
||||
apiKey: process.env.AZURE_API_KEY,
|
||||
baseURL: process.env.AZURE_API_URL,
|
||||
}),
|
||||
google: createGoogleGenerativeAI({
|
||||
apiKey: process.env.GOOGLE_API_KEY,
|
||||
baseURL: process.env.GOOGLE_API_URL,
|
||||
}),
|
||||
xai: createXai({
|
||||
apiKey: process.env.XAI_API_KEY,
|
||||
baseURL: process.env.XAI_API_URL,
|
||||
}),
|
||||
};
|
||||
|
||||
// We exclude this by default, because the Claude Code
|
||||
// API key is not supported by Anthropic endpoints.
|
||||
if (process.env.ANTHROPIC_API_KEY) {
|
||||
providers.anthropic = createAnthropic({
|
||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||
baseURL: process.env.ANTHROPIC_API_URL,
|
||||
});
|
||||
}
|
||||
|
||||
const proxyURL = createAnthropicProxy({
|
||||
providers,
|
||||
});
|
||||
|
||||
if (process.env.PROXY_ONLY === "true") {
|
||||
console.log("Proxy only mode: "+proxyURL);
|
||||
} else {
|
||||
const claudeArgs = process.argv.slice(2);
|
||||
const proc = spawn("claude", claudeArgs, {
|
||||
env: {
|
||||
...process.env,
|
||||
ANTHROPIC_BASE_URL: proxyURL,
|
||||
},
|
||||
stdio: "inherit",
|
||||
});
|
||||
proc.on("exit", (code) => {
|
||||
if (claudeArgs[0] === "-h" || claudeArgs[0] === "--help") {
|
||||
console.log("\nCustom Models:")
|
||||
console.log(" --model <provider>/<model> e.g. openai/o3");
|
||||
}
|
||||
|
||||
process.exit(code);
|
||||
});
|
||||
}
|
||||
|
||||
17
src/split-data-url.ts
Normal file
17
src/split-data-url.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export function splitDataUrl(dataUrl: string): {
|
||||
mimeType: string | undefined;
|
||||
base64Content: string | undefined;
|
||||
} {
|
||||
try {
|
||||
const [header, base64Content] = dataUrl.split(",");
|
||||
return {
|
||||
mimeType: header?.split(";")[0]?.split(":")[1],
|
||||
base64Content,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
mimeType: undefined,
|
||||
base64Content: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["esnext"],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue