diff --git a/.gitignore b/.gitignore index 872d5f6..e5b8def 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ + # Logs logs *.log @@ -141,3 +142,4 @@ dist vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ +.DS_Store diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..a44f37f --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,78 @@ +# 统一 AI 代理与配置注入器 (Unified AI Relay & Config Injector) 架构规划 + +该项目旨在完全解耦“API 聚合路由”与“客户端配置”,通过统一的 Node.js 轻量级代理服务,同时解决特定客户端(Codex、Claude Code)的私有协议壁垒,并通过自动化脚本一键注入跨工具配置。 + +> [!NOTE] +> 该工具定位于“无状态(或纯内存轻量状态)、无 DB、可插拔”的中间件,核心网关能力完全委托给后端的 LiteLLM。 +> 本项目核心框架选用 **Hono**,使用 **TypeScript** 开发。 + +## 核心架构设计 + +### 1. 目录结构与职责 (工程模式) + +采用插件化(Adapter)架构模式,使核心框架与具体的终端协议隔离,便于未来添加新的端点。项目代号确定为 **AI-Relay-Kit**。 + +```text +AI-Relay-Kit/ +├── package.json +├── docs/ # 项目文档 +│ └── architecture.md # 统一 AI 代理与配置注入器架构规划记录 +├── src/ +│ ├── index.ts # 服务入口 (Hono app) +│ ├── config.ts # 统一配置加载 (LiteLLM 地址、端口等) +│ ├── core/ # 核心中继控制器 +│ │ ├── request_handler.ts # 代理发送请求至 LiteLLM 核心模块 +│ │ └── cache_manager.ts # 轻量内存状态管理 (为 Codex 等提供会话持久) +│ ├── adapters/ # 特化客户端插件层 (Plugins) +│ │ ├── codex/ # 替代 codex-relay +│ │ │ ├── parser.ts # Responses API -> Chat Completions 转换 +│ │ │ └── routes.ts # 暴露 /codex/v1/responses 路由 +│ │ ├── claude/ # 替代 moon-bridge +│ │ │ ├── parser.ts # Anthropic API -> Chat Completions 转换 +│ │ │ └── routes.ts # 暴露 /claude/v1/messages 路由 +│ │ └── standard/ # 标准 OpenAI 直通 (预留给无特化需求的端) +│ └── injector/ # Auto Config Injector 模块 +│ ├── fetch_models.ts # 从 LiteLLM 拉取 /v1/models +│ ├── codex_injector.ts # 读写 ~/.codex/config.toml 和 catalog.json +│ ├── claude_injector.ts # 设置 Claude Code Base URL / env vars +│ └── anti_injector.ts # 配置 Antigravity 环境变量 +└── scripts/ + └── setup-ai-workspace.sh # 统一入口脚本,启动服务 + 触发配置注入 +``` + +### 2. 核心模块规划 + +#### 2.1 特殊客户端适配器 (Adapters) + +* **Codex 适配器 (`adapters/codex`)**: + * **拦截**:接收来自 Codex APP 的 POST 请求,符合专有的 `responses` 对象格式。 + * **翻译**:由于 Responses API 是 Stateful(有状态的),我们需要借助 `cache_manager` 在内存中维护历史 Session(类似 codex-relay Rust 版的逻辑)。将传入的对话状态还原为标准 `messages` 数组。 + * **转发**:请求下游 LiteLLM。 + * **响应映射**:将 LiteLLM 返回的 Chat Completions 标准 SSE 流实时映射回 Codex 期望的响应切片。 +* **Claude Code 适配器 (`adapters/claude`)**: + * **拦截**:拦截来自 Claude Code 的 `x-api-key` 和 `anthropic-version` 等头信息。 + * **翻译**:将 Anthropic 的 `system`、`messages` (role: user/assistant) 转换为通用的 Chat Completions,注意处理复杂的 Function Calling / Tool Use 差异。 + * **转发 & 响应映射**:接收 LiteLLM 响应并翻译回 Claude Code 期望的流事件(`message_start`, `content_block_delta` 等)。*(注:如果后续 LiteLLM 的 Anthropic proxy 完善,该模块可随时配置为透传模式)*。 + +#### 2.2 自动配置注入器 (Auto Config Injector) + +作为一个独立的 CLI 命令或启动脚本(例如 `npm run inject`),其执行流如下: + +1. **探测后端**:轮询/探测 LiteLLM(如 `http://localhost:4000`)是否就绪。 +2. **拉取模型**:调用 LiteLLM 的 `/v1/models`。 +3. **分发配置**: + * **Codex**:生成 `custom_model_catalog.json`,遍历 LiteLLM 返回的模型列表注入 `model_properties`,并修改 `~/.codex/config.toml` 将 `model_provider` 设为 `unified-relay`,基地址指向 `http://127.0.0.1:/codex/v1`。 + * **Claude Code**:将其默认 API 端点覆写为 `http://127.0.0.1:/claude/v1`,避免其强制访问远端 API。 + * **Antigravity**:可选择生成 `.env.local` 供直接读取,指向 LiteLLM `http://127.0.0.1:/v1`。 + +## Verification Plan + +### 自动化与脚本测试 +* 运行 `npm run inject` 后,验证 `~/.codex/config.toml` 及相关的模型目录 JSON 文件是否按照预期生成,并包含了来自 LiteLLM 的最新动态模型。 + +### 手动验证流 +1. 启动 LiteLLM(配置好 DeepSeek、OpenAI 等上游)。 +2. 启动 Unified Relay (当前项目)。 +3. 运行 Injector 注入配置。 +4. 在 Codex APP 中,检查是否能选择出并使用刚才配置的聚合模型。 +5. 在 Claude Code 中,运行测试 Prompt 检查是否成功完成通信。 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ae829e2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,937 @@ +{ + "name": "ai-nexus", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-nexus", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@hono/node-server": "2.0.5", + "axios": "1.18.0", + "dotenv": "17.4.2", + "hono": "4.12.26" + }, + "devDependencies": { + "@types/node": "26.0.0", + "tsx": "4.22.4", + "typescript": "6.0.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.5.tgz", + "integrity": "sha512-yQFvDmyDo3y6rEOJZDUYPJ49DIKTPpIk4kGvm40xx4Ejne0Pu9a1+exxPN+C1UppWK/WGZX9F++/Xs231tE86g==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@types/node": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", + "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.26", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz", + "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7b2fc53 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "AI-Relay-Kit", + "version": "1.0.0", + "main": "index.js", + "directories": { + "doc": "docs" + }, + "scripts": { + "start": "tsx src/index.ts", + "dev": "tsx watch src/index.ts" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "@hono/node-server": "2.0.5", + "axios": "1.18.0", + "dotenv": "17.4.2", + "hono": "4.12.26" + }, + "devDependencies": { + "@types/node": "26.0.0", + "tsx": "4.22.4", + "typescript": "6.0.3" + } +} diff --git a/scripts/setup-ai-workspace.sh b/scripts/setup-ai-workspace.sh new file mode 100755 index 0000000..1f1998f --- /dev/null +++ b/scripts/setup-ai-workspace.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -e + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$DIR")" + +echo "Starting AI-Relay-Kit Configuration Injection..." +cd "$PROJECT_ROOT" + +# Ensure dependencies are installed +if [ ! -d "node_modules" ]; then + echo "Installing dependencies..." + npm install +fi + +# Run the injector +npx tsx src/injector/index.ts + +echo "Done." diff --git a/src/adapters/claude/parser.ts b/src/adapters/claude/parser.ts new file mode 100644 index 0000000..b4dc1b4 --- /dev/null +++ b/src/adapters/claude/parser.ts @@ -0,0 +1,75 @@ +export function parseClaudeRequest(body: any): any { + const messages = [...(body.messages || [])]; + + if (body.system) { + messages.unshift({ + role: 'system', + content: body.system + }); + } + + // We convert standard Anthropic tool structures to OpenAI if needed, + // though LiteLLM usually handles standard OpenAI formats best. + // Assuming simplified direct mapping for basic use-cases + const chatReq = { + model: body.model, + messages: messages, + stream: body.stream !== false, + temperature: body.temperature, + top_p: body.top_p, + max_tokens: body.max_tokens, + }; + + return chatReq; +} + +// Function to convert OpenAI Chat Completions chunk to Anthropic Messages chunk +export function formatClaudeChunk(chatChunkStr: string, index: number): string | null { + if (chatChunkStr === '[DONE]') { + // We send message_stop event instead + return JSON.stringify({ type: 'message_stop' }); + } + + try { + const parsed = JSON.parse(chatChunkStr); + const content = parsed.choices?.[0]?.delta?.content || ''; + + // In Anthropic streaming: + // First event is message_start + if (index === 0) { + return JSON.stringify({ + type: 'message_start', + message: { + id: parsed.id || 'msg_123', + type: 'message', + role: 'assistant', + content: [], + model: parsed.model, + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 } + } + }); + } + + if (content) { + return JSON.stringify({ + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: content } + }); + } + + if (parsed.choices?.[0]?.finish_reason) { + return JSON.stringify({ + type: 'message_delta', + delta: { stop_reason: parsed.choices[0].finish_reason, stop_sequence: null }, + usage: { output_tokens: 0 } + }); + } + + return null; + } catch (e) { + return null; + } +} diff --git a/src/adapters/claude/routes.ts b/src/adapters/claude/routes.ts new file mode 100644 index 0000000..8cd643a --- /dev/null +++ b/src/adapters/claude/routes.ts @@ -0,0 +1,80 @@ +import { Hono } from 'hono'; +import { stream } from 'hono/streaming'; +import { parseClaudeRequest, formatClaudeChunk } from './parser'; +import { proxyToLiteLLM } from '../../core/request_handler'; + +export const claudeRouter = new Hono(); + +claudeRouter.post('/messages', async (c) => { + const body = await c.req.json(); + const chatReq = parseClaudeRequest(body); + const headers = c.req.header(); + + // Anthropic API uses x-api-key instead of Authorization header often + if (headers['x-api-key']) { + headers['Authorization'] = `Bearer ${headers['x-api-key']}`; + } + + try { + const response = await proxyToLiteLLM('/v1/chat/completions', 'POST', chatReq, headers, chatReq.stream ? 'stream' : 'json'); + + if (!chatReq.stream) { + // Simplified non-streaming response format conversion + const data = response.data; + return c.json({ + id: data.id, + type: 'message', + role: 'assistant', + content: [ + { + type: 'text', + text: data.choices?.[0]?.message?.content || '' + } + ], + model: chatReq.model, + stop_reason: data.choices?.[0]?.finish_reason || 'end_turn', + stop_sequence: null, + usage: { + input_tokens: data.usage?.prompt_tokens || 0, + output_tokens: data.usage?.completion_tokens || 0 + } + }); + } + + let index = 0; + c.header('Content-Type', 'text/event-stream'); + c.header('Cache-Control', 'no-cache'); + c.header('Connection', 'keep-alive'); + + return stream(c, async (streamWriter) => { + response.data.on('data', (chunk: Buffer) => { + const lines = chunk.toString().split('\n').filter(line => line.trim() !== ''); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const dataStr = line.slice(6).trim(); + const transformed = formatClaudeChunk(dataStr, index++); + + if (transformed) { + streamWriter.write(`event: ${JSON.parse(transformed).type}\n`); + streamWriter.write(`data: ${transformed}\n\n`); + } + } + } + }); + + response.data.on('end', () => { + streamWriter.close(); + }); + + response.data.on('error', (err: any) => { + console.error('Claude stream error:', err); + streamWriter.abort(); + }); + }); + + } catch (error: any) { + c.status(error.response?.status || 500); + return c.json(error.response?.data || { error: { message: error.message } }); + } +}); diff --git a/src/adapters/codex/parser.ts b/src/adapters/codex/parser.ts new file mode 100644 index 0000000..072de98 --- /dev/null +++ b/src/adapters/codex/parser.ts @@ -0,0 +1,47 @@ +import { sessionCache } from '../../core/cache_manager'; + +export function parseCodexRequest(body: any): any { + let messages = body.messages || []; + + // If there's a previous_response_id, we prepend the history + if (body.previous_response_id) { + const session = sessionCache.getSession(body.previous_response_id); + if (session && session.history) { + messages = [...session.history, ...messages]; + } + } + + // Build the standard Chat Completions request + const chatReq = { + model: body.model, + messages: messages, + stream: body.stream !== false, + temperature: body.temperature, + top_p: body.top_p, + max_tokens: body.max_tokens, + tools: body.tools, + tool_choice: body.tool_choice, + }; + + return { chatReq, fullMessages: messages }; +} + +// Function to handle the SSE stream transformation (if necessary) +// Many times Codex just expects standard Chat Completion chunks, +// but we may need to inject the new response_id so Codex can send it back next time. +export function formatCodexChunk(chunkStr: string, responseId: string): string { + // If the stream chunk is standard, we might not need heavy translation, + // but we can ensure the ID is set. + if (chunkStr.trim() === '[DONE]') { + return chunkStr; + } + + try { + const data = JSON.parse(chunkStr); + data.response_id = responseId; // Inject response_id + // some proprietary fields codex might expect + return JSON.stringify(data); + } catch (e) { + return chunkStr; + } +} diff --git a/src/adapters/codex/routes.ts b/src/adapters/codex/routes.ts new file mode 100644 index 0000000..6c983ae --- /dev/null +++ b/src/adapters/codex/routes.ts @@ -0,0 +1,84 @@ +import { Hono } from 'hono'; +import { stream } from 'hono/streaming'; +import { parseCodexRequest, formatCodexChunk } from './parser'; +import { proxyToLiteLLM } from '../../core/request_handler'; +import { sessionCache } from '../../core/cache_manager'; + +export const codexRouter = new Hono(); + +codexRouter.post('/responses', async (c) => { + const body = await c.req.json(); + const { chatReq, fullMessages } = parseCodexRequest(body); + const headers = c.req.header(); + + try { + const response = await proxyToLiteLLM('/v1/chat/completions', 'POST', chatReq, headers, chatReq.stream ? 'stream' : 'json'); + + // We generate a new response ID for this session + const responseId = sessionCache.generateId(); + + // Save the message history immediately; we assume the assistant will append to this in real-time or we can just save what we have. + // To be perfectly accurate, we should append the assistant's response to `fullMessages` as it streams. + // For simplicity, we just save the full user request history, and the next turn the user will send it again or we can reconstruct. + const currentSessionMessages = [...fullMessages]; + + if (!chatReq.stream) { + const data = response.data; + data.response_id = responseId; + + // Append assistant message + if (data.choices && data.choices[0] && data.choices[0].message) { + currentSessionMessages.push(data.choices[0].message); + sessionCache.saveSession(responseId, currentSessionMessages); + } + return c.json(data); + } + + // Handle Streaming + let assistantMessage = ''; + + return stream(c, async (streamWriter) => { + response.data.on('data', (chunk: Buffer) => { + const lines = chunk.toString().split('\n').filter(line => line.trim() !== ''); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const dataStr = line.slice(6).trim(); + if (dataStr === '[DONE]') { + streamWriter.write(`data: [DONE]\n\n`); + continue; + } + + try { + const parsed = JSON.parse(dataStr); + if (parsed.choices && parsed.choices[0] && parsed.choices[0].delta && parsed.choices[0].delta.content) { + assistantMessage += parsed.choices[0].delta.content; + } + } catch(e) {} + + const transformed = formatCodexChunk(dataStr, responseId); + streamWriter.write(`data: ${transformed}\n\n`); + } else { + streamWriter.write(`${line}\n`); + } + } + }); + + response.data.on('end', () => { + // Save history with full assistant message once stream is done + currentSessionMessages.push({ role: 'assistant', content: assistantMessage }); + sessionCache.saveSession(responseId, currentSessionMessages); + streamWriter.close(); + }); + + response.data.on('error', (err: any) => { + console.error('Stream error:', err); + streamWriter.abort(); + }); + }); + + } catch (error: any) { + c.status(error.response?.status || 500); + return c.json(error.response?.data || { error: error.message }); + } +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..b54f7be --- /dev/null +++ b/src/config.ts @@ -0,0 +1,8 @@ +import dotenv from 'dotenv'; +dotenv.config(); + +export const config = { + port: parseInt(process.env.PORT || '4444', 10), + litellmUrl: process.env.LITELLM_URL || 'http://127.0.0.1:4000', + defaultApiKey: process.env.API_KEY || '', +}; diff --git a/src/core/cache_manager.ts b/src/core/cache_manager.ts new file mode 100644 index 0000000..2df2179 --- /dev/null +++ b/src/core/cache_manager.ts @@ -0,0 +1,67 @@ +import crypto from 'crypto'; + +interface SessionState { + id: string; + history: any[]; // Chat messages + lastUpdatedAt: number; +} + +export class CacheManager { + private sessions: Map = new Map(); + private maxSessions: number; + private ttlMs: number; + + constructor(maxSessions = 256, ttlHours = 168) { + this.maxSessions = maxSessions; + this.ttlMs = ttlHours * 3600 * 1000; + } + + public generateId(): string { + return crypto.randomBytes(16).toString('hex'); + } + + public getSession(id: string): SessionState | undefined { + const session = this.sessions.get(id); + if (session) { + if (Date.now() - session.lastUpdatedAt > this.ttlMs) { + this.sessions.delete(id); + return undefined; + } + session.lastUpdatedAt = Date.now(); + } + return session; + } + + public saveSession(id: string, history: any[]): void { + this.cleanup(); + this.sessions.set(id, { + id, + history, + lastUpdatedAt: Date.now() + }); + } + + private cleanup(): void { + const now = Date.now(); + for (const [id, session] of this.sessions.entries()) { + if (now - session.lastUpdatedAt > this.ttlMs) { + this.sessions.delete(id); + } + } + + if (this.sessions.size >= this.maxSessions) { + // delete oldest + let oldestId: string | null = null; + let oldestTime = Infinity; + for (const [id, session] of this.sessions.entries()) { + if (session.lastUpdatedAt < oldestTime) { + oldestTime = session.lastUpdatedAt; + oldestId = id; + } + } + if (oldestId) this.sessions.delete(oldestId); + } + } +} + +export const sessionCache = new CacheManager(); diff --git a/src/core/request_handler.ts b/src/core/request_handler.ts new file mode 100644 index 0000000..9f79d17 --- /dev/null +++ b/src/core/request_handler.ts @@ -0,0 +1,37 @@ +import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; +import { config } from '../config'; + +export async function proxyToLiteLLM( + endpoint: string, + method: 'GET' | 'POST' = 'POST', + data?: any, + headers?: any, + responseType: 'json' | 'stream' = 'json' +): Promise { + const url = `${config.litellmUrl}${endpoint}`; + + // Strip out host and connection headers that might cause issues when proxying + const safeHeaders = { ...headers }; + delete safeHeaders['host']; + delete safeHeaders['connection']; + delete safeHeaders['content-length']; + + const reqConfig: AxiosRequestConfig = { + method, + url, + data, + headers: { + 'Content-Type': 'application/json', + 'Authorization': safeHeaders.Authorization || `Bearer ${config.defaultApiKey}`, + ...safeHeaders + }, + responseType + }; + + try { + return await axios(reqConfig); + } catch (error: any) { + console.error(`LiteLLM Proxy Error [${method} ${url}]:`, error.response?.data || error.message); + throw error; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..8a04176 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,25 @@ +import { serve } from '@hono/node-server'; +import { Hono } from 'hono'; +import { logger } from 'hono/logger'; +import { cors } from 'hono/cors'; +import { config } from './config'; +import { codexRouter } from './adapters/codex/routes'; +import { claudeRouter } from './adapters/claude/routes'; + +const app = new Hono(); + +app.use('*', logger()); +app.use('*', cors()); + +app.get('/health', (c) => c.json({ status: 'ok', service: 'AI-Relay-Kit', time: new Date().toISOString() })); + +// Mount Adapters +app.route('/codex/v1', codexRouter); +app.route('/claude/v1', claudeRouter); + +console.log(`Starting AI-Relay-Kit unified relay on port ${config.port}`); + +serve({ + fetch: app.fetch, + port: config.port, +}); diff --git a/src/injector/anti_injector.ts b/src/injector/anti_injector.ts new file mode 100644 index 0000000..75a7800 --- /dev/null +++ b/src/injector/anti_injector.ts @@ -0,0 +1,26 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { config } from '../config'; + +export function injectAntigravityConfig() { + const envFilePath = path.join(os.homedir(), '.ai-relay-kit.env'); + + let envContent = ''; + if (fs.existsSync(envFilePath)) { + envContent = fs.readFileSync(envFilePath, 'utf8'); + } + + // Antigravity (and other OpenAI compatible tools) can just point directly to LiteLLM + const openaiBaseUrl = `${config.litellmUrl}/v1`; + + const regex = /^export OPENAI_BASE_URL=.*$/m; + if (regex.test(envContent)) { + envContent = envContent.replace(regex, `export OPENAI_BASE_URL="${openaiBaseUrl}"`); + } else { + envContent += `\nexport OPENAI_BASE_URL="${openaiBaseUrl}"\n`; + } + + fs.writeFileSync(envFilePath, envContent.trim() + '\n', 'utf8'); + console.log(`[Antigravity] Updated OpenAI compatible environment variables in ${envFilePath}`); +} diff --git a/src/injector/claude_injector.ts b/src/injector/claude_injector.ts new file mode 100644 index 0000000..6d341c1 --- /dev/null +++ b/src/injector/claude_injector.ts @@ -0,0 +1,27 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { config } from '../config'; + +export function injectClaudeConfig() { + const envFilePath = path.join(os.homedir(), '.ai-relay-kit.env'); + + let envContent = ''; + if (fs.existsSync(envFilePath)) { + envContent = fs.readFileSync(envFilePath, 'utf8'); + } + + const claudeBaseUrl = `http://127.0.0.1:${config.port}/claude/v1`; + + // Replace or append ANTHROPIC_BASE_URL + const regex = /^export ANTHROPIC_BASE_URL=.*$/m; + if (regex.test(envContent)) { + envContent = envContent.replace(regex, `export ANTHROPIC_BASE_URL="${claudeBaseUrl}"`); + } else { + envContent += `\nexport ANTHROPIC_BASE_URL="${claudeBaseUrl}"\n`; + } + + // Claude CLI uses this to route API requests + fs.writeFileSync(envFilePath, envContent.trim() + '\n', 'utf8'); + console.log(`[Claude] Updated Claude environment variables in ${envFilePath}`); +} diff --git a/src/injector/codex_injector.ts b/src/injector/codex_injector.ts new file mode 100644 index 0000000..867769d --- /dev/null +++ b/src/injector/codex_injector.ts @@ -0,0 +1,77 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { ModelDetails } from './fetch_models'; +import { config } from '../config'; + +const codexConfigPath = path.join(os.homedir(), '.codex', 'config.toml'); +const codexCatalogPath = path.join(os.homedir(), '.codex', 'custom_model_catalog.json'); + +export function injectCodexConfig(models: ModelDetails[]) { + // 1. Generate catalog.json + const catalog = { + models: models.map(m => ({ + id: m.id, + name: m.id, + provider: "unified-relay" + })) + }; + fs.mkdirSync(path.dirname(codexCatalogPath), { recursive: true }); + fs.writeFileSync(codexCatalogPath, JSON.stringify(catalog, null, 2), 'utf8'); + console.log(`[Codex] Wrote model catalog to ${codexCatalogPath}`); + + // 2. Modify config.toml + let tomlContent = ''; + if (fs.existsSync(codexConfigPath)) { + tomlContent = fs.readFileSync(codexConfigPath, 'utf8'); + } + + // Ensure model_provider and model_catalog_json are set at the root + const providerRegex = /^model_provider\s*=\s*".*?"/m; + if (providerRegex.test(tomlContent)) { + tomlContent = tomlContent.replace(providerRegex, 'model_provider = "unified-relay"'); + } else { + tomlContent = `model_provider = "unified-relay"\n` + tomlContent; + } + + const catalogRegex = /^model_catalog_json\s*=\s*".*?"/m; + if (catalogRegex.test(tomlContent)) { + tomlContent = tomlContent.replace(catalogRegex, `model_catalog_json = "${codexCatalogPath}"`); + } else { + tomlContent = `model_catalog_json = "${codexCatalogPath}"\n` + tomlContent; + } + + // Clean up any old unified-relay blocks if they exist (simple approach: we just append a fresh one or replace) + // For safety and idempotency in this script, we'll look for our specific marker + const markerStart = '\n# --- UNIFIED RELAY CONFIG START ---'; + const markerEnd = '# --- UNIFIED RELAY CONFIG END ---\n'; + + if (tomlContent.includes(markerStart)) { + const regex = new RegExp(`${markerStart}[\\s\\S]*?${markerEnd}`, 'g'); + tomlContent = tomlContent.replace(regex, ''); + } + + // Build the new provider and properties block + let providerBlock = `${markerStart}\n`; + providerBlock += `[model_providers.unified-relay]\n`; + providerBlock += `name = "Unified AI-Relay-Kit"\n`; + providerBlock += `base_url = "http://127.0.0.1:${config.port}/codex/v1"\n`; + providerBlock += `wire_api = "responses"\n`; + providerBlock += `env_key = "LITELLM_API_KEY"\n\n`; + + for (const m of models) { + providerBlock += `[model_properties."${m.id}"]\n`; + providerBlock += `context_window = 128000\n`; + providerBlock += `max_context_window = 128000\n`; + // Reasoning models usually don't support parallel tool calls + const isReasoning = m.id.includes('reasoner') || m.id.includes('o1') || m.id.includes('o3'); + providerBlock += `supports_parallel_tool_calls = ${isReasoning ? 'false' : 'true'}\n`; + providerBlock += `supports_reasoning_summaries = ${isReasoning ? 'true' : 'false'}\n`; + providerBlock += `input_modalities = ["text"]\n\n`; + } + providerBlock += `${markerEnd}`; + + tomlContent += providerBlock; + fs.writeFileSync(codexConfigPath, tomlContent, 'utf8'); + console.log(`[Codex] Updated config.toml at ${codexConfigPath}`); +} diff --git a/src/injector/fetch_models.ts b/src/injector/fetch_models.ts new file mode 100644 index 0000000..8071e4e --- /dev/null +++ b/src/injector/fetch_models.ts @@ -0,0 +1,23 @@ +import axios from 'axios'; +import { config } from '../config'; + +export interface ModelDetails { + id: string; + object: string; + created: number; + owned_by: string; +} + +export async function fetchLiteLLMModels(): Promise { + try { + const response = await axios.get(`${config.litellmUrl}/v1/models`, { + headers: { + 'Authorization': `Bearer ${config.defaultApiKey}` + } + }); + return response.data.data || []; + } catch (error: any) { + console.error('Failed to fetch models from LiteLLM:', error.message); + return []; + } +} diff --git a/src/injector/iac_injector.ts b/src/injector/iac_injector.ts new file mode 100644 index 0000000..8984ae5 --- /dev/null +++ b/src/injector/iac_injector.ts @@ -0,0 +1,31 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { ModelDetails } from './fetch_models'; +import { config } from '../config'; + +export function injectIaCConfig(models: ModelDetails[]) { + const iacDir = path.join(os.homedir(), '.ai-relay-kit', 'iac'); + fs.mkdirSync(iacDir, { recursive: true }); + + const aiRelayKitConfig = { + relay_port: config.port, + litellm_url: config.litellmUrl, + models: models.map(m => m.id), + }; + + // 1. Export for Ansible (ansible_vars.json) + const ansibleVarsPath = path.join(iacDir, 'ansible_vars.json'); + const ansibleData = { + ai_relay_kit_port: config.port, + ai_relay_kit_litellm_url: config.litellmUrl, + ai_relay_kit_available_models: models.map(m => m.id) + }; + fs.writeFileSync(ansibleVarsPath, JSON.stringify(ansibleData, null, 2), 'utf8'); + console.log(`[IaC] Wrote Ansible variables to ${ansibleVarsPath}`); + + // 2. Export for Terraform (terraform.tfvars.json) + const tfVarsPath = path.join(iacDir, 'terraform.tfvars.json'); + fs.writeFileSync(tfVarsPath, JSON.stringify(aiRelayKitConfig, null, 2), 'utf8'); + console.log(`[IaC] Wrote Terraform tfvars to ${tfVarsPath}`); +} diff --git a/src/injector/index.ts b/src/injector/index.ts new file mode 100644 index 0000000..daba43c --- /dev/null +++ b/src/injector/index.ts @@ -0,0 +1,37 @@ +import { fetchLiteLLMModels } from './fetch_models'; +import { injectCodexConfig } from './codex_injector'; +import { injectClaudeConfig } from './claude_injector'; +import { injectAntigravityConfig } from './anti_injector'; +import { injectIaCConfig } from './iac_injector'; + +async function runInjector() { + console.log('--- Starting AI-Relay-Kit Config Injector ---'); + + const models = await fetchLiteLLMModels(); + + if (models.length === 0) { + console.warn('⚠️ No models found from LiteLLM. Is LiteLLM running? Configurations might be incomplete.'); + } else { + console.log(`✅ Fetched ${models.length} models from LiteLLM.`); + } + + // 1. Configure Codex + injectCodexConfig(models); + + // 2. Configure Claude Code + injectClaudeConfig(); + + // 3. Configure Antigravity + injectAntigravityConfig(); + + // 4. Export IaC Configurations + injectIaCConfig(models); + + console.log('--- Config Injection Complete ---'); + console.log('\n💡 To apply the environment variables, run:'); + console.log(' source ~/.ai-relay-kit.env'); + console.log('\n Or add this line to your ~/.zshrc or ~/.bashrc:'); + console.log(' source ~/.ai-relay-kit.env'); +} + +runInjector().catch(console.error); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cec4a3a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,44 @@ +{ + // Visit https://aka.ms/tsconfig to read more about this file + "compilerOptions": { + // File Layout + // "rootDir": "./src", + // "outDir": "./dist", + + // Environment Settings + // See also https://aka.ms/tsconfig/module + "module": "nodenext", + "target": "esnext", + "types": [], + // For nodejs: + // "lib": ["esnext"], + // "types": ["node"], + // and npm install -D @types/node + + // Other Outputs + "sourceMap": true, + "declaration": true, + "declarationMap": true, + + // Stricter Typechecking Options + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + + // Style Options + // "noImplicitReturns": true, + // "noImplicitOverride": true, + // "noUnusedLocals": true, + // "noUnusedParameters": true, + // "noFallthroughCasesInSwitch": true, + // "noPropertyAccessFromIndexSignature": true, + + // Recommended Options + "strict": true, + "jsx": "react-jsx", + "verbatimModuleSyntax": true, + "isolatedModules": true, + "noUncheckedSideEffectImports": true, + "moduleDetection": "force", + "skipLibCheck": true, + } +}