Initial commit of AI-Relay-Kit

This commit is contained in:
Haitao Pan 2026-06-21 14:30:10 +08:00
parent 7ceeb612c4
commit 86cb262e71
20 changed files with 1751 additions and 0 deletions

2
.gitignore vendored
View File

@ -1,3 +1,4 @@
# Logs # Logs
logs logs
*.log *.log
@ -141,3 +142,4 @@ dist
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
.vite/ .vite/
.DS_Store

78
docs/architecture.md Normal file
View File

@ -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:<RelayPort>/codex/v1`
* **Claude Code**:将其默认 API 端点覆写为 `http://127.0.0.1:<RelayPort>/claude/v1`,避免其强制访问远端 API。
* **Antigravity**:可选择生成 `.env.local` 供直接读取,指向 LiteLLM `http://127.0.0.1:<LiteLLMPort>/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 检查是否成功完成通信。

937
package-lock.json generated Normal file
View File

@ -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"
}
}
}

27
package.json Normal file
View File

@ -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"
}
}

19
scripts/setup-ai-workspace.sh Executable file
View File

@ -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."

View File

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

View File

@ -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 } });
}
});

View File

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

View File

@ -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 });
}
});

8
src/config.ts Normal file
View File

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

67
src/core/cache_manager.ts Normal file
View File

@ -0,0 +1,67 @@
import crypto from 'crypto';
interface SessionState {
id: string;
history: any[]; // Chat messages
lastUpdatedAt: number;
}
export class CacheManager {
private sessions: Map<string, SessionState> = 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();

View File

@ -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<AxiosResponse> {
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;
}
}

25
src/index.ts Normal file
View File

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

View File

@ -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}`);
}

View File

@ -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}`);
}

View File

@ -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}`);
}

View File

@ -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<ModelDetails[]> {
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 [];
}
}

View File

@ -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}`);
}

37
src/injector/index.ts Normal file
View File

@ -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);

44
tsconfig.json Normal file
View File

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