commit 0dd6fe2c7d66cb3cf3bc50a19cd2088d705bb967 Author: mars Date: Wed Jan 7 10:51:54 2026 +0800 feat: 实现ChatGPT Codex路由器的核心功能 - 添加完整的项目基础结构,包括配置、类型定义和常量 - 实现OAuth认证流程和令牌管理 - 开发请求转换和响应处理逻辑 - 添加SSE流处理和ChatCompletions API转换 - 实现模型映射和提示指令系统 - 包含Docker部署配置和快速启动文档 - 添加自动登录功能和测试脚本 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b8623d --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Build output +dist/ +build/ +*.tsbuildinfo + +# Environment variables +.env +.env.local +.env.*.local + +# Logs +logs/ +*.log + +# Data directory +data/ +*.json +!package.json +!package-lock.json +!tsconfig.json + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Test coverage +coverage/ +*.lcov + +# Temporary files +tmp/ +temp/ +*.tmp + +# Docker volumes +docker/data/ +docker/logs/ + +# Keep directory placeholders +!.gitkeep +!data/.gitkeep +!logs/.gitkeep diff --git a/AUTO_LOGIN.md b/AUTO_LOGIN.md new file mode 100644 index 0000000..d3dcdf9 --- /dev/null +++ b/AUTO_LOGIN.md @@ -0,0 +1,123 @@ +# Auto Login Feature + +## Overview + +The chatgpt-codex-router now automatically checks authentication status on startup and initiates OAuth login if no valid token is found. + +## How It Works + +1. **Startup Check**: When the server starts, it checks for an existing authentication token +2. **Token Validation**: If a token exists, it validates whether it's expired +3. **Auto Login**: If no token is found or the token is expired, the server automatically: + - Generates OAuth authorization flow + - Starts local OAuth callback server on port 1455 + - Attempts to open browser automatically + - Displays OAuth URL in logs for manual access + +## Startup Behavior + +### ✅ Valid Token Found + +``` +[INFO] Authentication token found and valid. +[INFO] Server started on http://0.0.0.0:3000 +``` + +### ⚠️ No Token Found + +``` +[WARN] No authentication token found. Initiating OAuth login... +[INFO] Starting OAuth flow with state: ... +[INFO] Local OAuth server started on port 1455 +[INFO] OAuth login initiated. +[INFO] Please complete the OAuth flow in your browser. +[INFO] OAuth URL: https://auth.openai.com/oauth/authorize?... +[INFO] Browser should have opened automatically. +[INFO] Server started on http://0.0.0.0:3000 +``` + +### ⚠️ Token Expired + +``` +[WARN] Authentication token expired. Please login again. +[INFO] Starting OAuth flow with state: ... +... +``` + +## Manual Login + +If you want to manually trigger OAuth login after the server is running: + +```bash +curl -X POST http://localhost:3000/auth/login +``` + +## Configuration + +### Disable Auto Login + +If you want to disable auto-login, you can: +1. Create a dummy token file (not recommended) +2. Modify the check logic in `src/index.ts` + +### Custom OAuth Server Port + +To use a different port for the OAuth callback server, modify your config: + +```json +{ + "oauth": { + "localServerPort": 1456 + } +} +``` + +Or set via environment variable: + +```bash +OAUTH_LOCAL_SERVER_PORT=1456 npm start +``` + +## Token Storage + +Tokens are stored in: +- **Linux/Mac**: `~/.chatgpt-codex-router/tokens.json` +- **Windows**: `C:\Users\\.chatgpt-codex-router\tokens.json` + +Token file structure: +```json +{ + "access_token": "...", + "refresh_token": "...", + "expires_at": 1234567890, + "account_id": "...", + "updated_at": 1234567890 +} +``` + +## Troubleshooting + +### Port 1455 Already in Use + +If you see the error: +``` +[WARN] OAuth server not ready, manual login required. +``` + +It means port 1455 is already in use. You can: +1. Kill the process using port 1455: `lsof -ti:1455 | xargs kill -9` +2. Use a different port via configuration +3. Manually login using: `curl -X POST http://localhost:3000/auth/login` + +### Browser Not Opening + +If the browser doesn't open automatically: +1. Copy the OAuth URL from the logs +2. Paste it in your browser manually + +### OAuth Callback Failing + +If OAuth callback fails: +1. Check that the OAuth callback server is running on port 1455 +2. Verify firewall settings +3. Check logs in `logs/` directory for detailed error messages diff --git a/QUICK_START.md b/QUICK_START.md new file mode 100644 index 0000000..19811e5 --- /dev/null +++ b/QUICK_START.md @@ -0,0 +1,123 @@ +# Quick Start Guide + +## First Time Setup (Auto Login) + +1. **Start the server:** + ```bash + cd /home/mars/project/chatgpt-codex-router + npm start + ``` + +2. **Watch for auto-login messages:** + ``` + [WARN] No authentication token found. Initiating OAuth login... + [INFO] OAuth login initiated. + [INFO] Please complete the OAuth flow in your browser. + ``` + +3. **Complete OAuth in your browser:** + - The browser should open automatically + - Login to your ChatGPT account + - Authorize the application + - The browser will show "Authentication Successful" + +4. **Server is now ready:** + - Token is saved to `~/.chatgpt-codex-router/tokens.json` + - Server is running on http://localhost:3000 + +## Subsequent Starts + +After first authentication, subsequent starts will show: +``` +[INFO] Authentication token found and valid. +[INFO] Server started on http://0.0.0.0:3000 +``` + +No login required! + +## Making API Calls + +### Simple Chat Request +```bash +curl http://localhost:3000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-5.2-codex", + "messages": [ + {"role": "user", "content": "Hello, world!"} + ] + }' +``` + +### Streaming Request +```bash +curl http://localhost:3000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-5.2-codex", + "messages": [ + {"role": "user", "content": "Tell me a joke"} + ], + "stream": true + }' +``` + +## Available Models + +- `gpt-5.1` - General purpose (none/low/medium/high) +- `gpt-5.2` - General purpose (none/low/medium/high/xhigh) +- `gpt-5.1-codex` - Coding (low/medium/high) +- `gpt-5.1-codex-max` - Advanced coding (low/medium/high/xhigh) +- `gpt-5.1-codex-mini` - Quick coding (medium/high) +- `gpt-5.2-codex` - Latest coding (low/medium/high/xhigh) + +## Troubleshooting + +### Port Already in Use +If port 1455 is occupied: +```bash +# Find and kill the process +lsof -ti:1455 | xargs kill -9 + +# Or use a different port +# (See AUTO_LOGIN.md for configuration options) +``` + +### Manual Login +If auto-login fails: +```bash +curl -X POST http://localhost:3000/auth/login +``` + +### View Logs +```bash +# All logs +tail -f logs/*-info.log + +# Errors only +tail -f logs/*-error.log + +# Warnings only +tail -f logs/*-warn.log +``` + +## Configuration + +Create `~/.chatgpt-codex-router/config.json`: +```json +{ + "server": { + "port": 3000 + }, + "logging": { + "level": "info", + "enableRequestLogging": false + } +} +``` + +## More Information + +- **Full Documentation**: See [README.md](README.md) +- **Auto-Login Details**: See [AUTO_LOGIN.md](AUTO_LOGIN.md) +- **Project Plan**: See [plan.md](plan.md) diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d384c4 --- /dev/null +++ b/README.md @@ -0,0 +1,349 @@ +# ChatGPT Codex Router + +An OpenAI-compatible API router that forwards requests to the ChatGPT backend with OAuth authentication. + +## Features + +- **OpenAI Compatible API**: Supports `/v1/chat/completions` and `/v1/responses` endpoints +- **OAuth Authentication**: Uses OpenAI's official OAuth flow (same as Codex CLI) +- **GPT-5.x Models**: Supports all GPT-5.1 and GPT-5.2 model variants +- **Streaming Support**: Full support for streaming responses +- **Automatic Token Refresh**: Automatically refreshes expired OAuth tokens +- **Detailed Logging**: Configurable logging with request/response tracking +- **Docker Support**: Ready-to-deploy Docker images + +## Supported Models + +- `gpt-5.1` (none/low/medium/high) +- `gpt-5.2` (none/low/medium/high/xhigh) +- `gpt-5.1-codex` (low/medium/high) +- `gpt-5.1-codex-max` (low/medium/high/xhigh) +- `gpt-5.1-codex-mini` (medium/high) +- `gpt-5.2-codex` (low/medium/high/xhigh) + +## Features + +- **OpenAI Compatible API**: Supports `/v1/chat/completions` and `/v1/responses` endpoints +- **OAuth Authentication**: Uses OpenAI's official OAuth flow (same as Codex CLI) +- **Auto Login**: Automatically checks authentication on startup and initiates OAuth login if needed +- **GPT-5.x Models**: Supports all GPT-5.1 and GPT-5.2 model variants +- **Streaming Support**: Full support for streaming responses +- **Automatic Token Refresh**: Automatically refreshes expired OAuth tokens +- **Detailed Logging**: Configurable logging with request/response tracking +- **Docker Support**: Ready-to-deploy Docker images + +## Installation + +### Using NPM + +```bash +npm install -g chatgpt-codex-router +``` + +### Using Docker + +```bash +docker build -t chatgpt-codex-router -f docker/Dockerfile . +``` + +## Quick Start + +### Development + +```bash +# Install dependencies +npm install + +# Start development server +npm run dev +``` + +### Production + +```bash +# Build the project +npm run build + +# Start production server +npm start +``` + +### Using Docker + +```bash +# Start with docker-compose +docker-compose -f docker/docker-compose.yml up -d + +# View logs +docker-compose -f docker/docker-compose.yml logs -f +``` + +## Usage + +### Auto Login (Recommended) + +The server now automatically checks authentication status on startup: + +```bash +npm start +``` + +If no valid token is found, the server will: +1. Automatically initiate OAuth flow +2. Start local OAuth callback server (port 1455) +3. Attempt to open browser automatically +4. Display OAuth URL in logs for manual access + +**Startup Logs:** +``` +[WARN] No authentication token found. Initiating OAuth login... +[INFO] Starting OAuth flow with state: ... +[INFO] Local OAuth server started on port 1455 +[INFO] OAuth login initiated. +[INFO] Please complete the OAuth flow in your browser. +[INFO] OAuth URL: https://auth.openai.com/oauth/authorize?... +[INFO] Browser should have opened automatically. +[INFO] Server started on http://0.0.0.0:3000 +``` + +For detailed information about the auto-login feature, see [AUTO_LOGIN.md](AUTO_LOGIN.md). + +### 1. Manual OAuth Login + +If you need to manually trigger authentication after server is running: + +```bash +curl -X POST http://localhost:3000/auth/login +``` + +This will return an authorization URL. Open it in your browser to complete the OAuth flow. After successful authentication, the token will be saved to `~/.chatgpt-codex-router/tokens.json`. + +### 2. Chat Completions + +Use the standard OpenAI Chat Completions API: + +```bash +curl http://localhost:3000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-5.2-codex", + "messages": [ + {"role": "user", "content": "Hello, how are you?"} + ], + "stream": false + }' +``` + +### 3. Streaming Responses + +Enable streaming with `stream: true`: + +```bash +curl http://localhost:3000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-5.2-codex", + "messages": [ + {"role": "user", "content": "Write a Python function"} + ], + "stream": true + }' +``` + +### 4. Responses API + +Use the OpenAI Responses API: + +```bash +curl http://localhost:3000/v1/responses \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-5.2-codex", + "input": [ + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Hello"}]} + ], + "stream": false + }' +``` + +## Configuration + +Create a configuration file at `~/.chatgpt-codex-router/config.json`: + +```json +{ + "server": { + "port": 3000, + "host": "0.0.0.0" + }, + "oauth": { + "clientId": "app_EMoamEEZ73f0CkXaXp7hrann", + "redirectUri": "http://localhost:1455/auth/callback", + "localServerPort": 1455 + }, + "backend": { + "url": "https://chatgpt.com/backend-api", + "timeout": 120000 + }, + "logging": { + "level": "info", + "dir": "./logs", + "enableRequestLogging": false + }, + "codex": { + "mode": true, + "defaultReasoningEffort": "medium", + "defaultTextVerbosity": "medium" + } +} +``` + +### Environment Variables + +- `PORT`: Server port (default: 3000) +- `LOG_LEVEL`: Logging level (error/warn/info/debug) +- `ENABLE_REQUEST_LOGGING`: Enable detailed request logging (true/false) +- `CONFIG_PATH`: Custom configuration file path +- `DATA_DIR`: Custom data directory for tokens + +## API Endpoints + +### `GET /health` + +Health check endpoint. + +**Response:** +```json +{ + "status": "healthy", + "timestamp": 1736153600000 +} +``` + +### `POST /auth/login` + +Initiate OAuth authentication flow. + +**Response:** +```json +{ + "status": "pending", + "url": "https://auth.openai.com/oauth/authorize?...", + "instructions": "Please complete the OAuth flow in your browser" +} +``` + +### `POST /v1/chat/completions` + +OpenAI-compatible chat completions endpoint. + +**Request:** +```json +{ + "model": "gpt-5.2-codex", + "messages": [ + {"role": "user", "content": "Hello"} + ], + "stream": false, + "temperature": 0.7 +} +``` + +**Response:** +```json +{ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1736153600, + "model": "gpt-5.2-codex", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "Hello! How can I help you today?" + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 20, + "total_tokens": 30 + } +} +``` + +### `POST /v1/responses` + +OpenAI Responses API endpoint. + +**Request:** +```json +{ + "model": "gpt-5.2-codex", + "input": [ + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Hello"}]} + ], + "stream": false +} +``` + +## Project Structure + +``` +chatgpt-codex-router/ +├── src/ +│ ├── auth/ # OAuth authentication +│ ├── request/ # Request transformation +│ ├── response/ # Response handling +│ ├── prompts/ # Codex system prompts +│ ├── config.ts # Configuration management +│ ├── logger.ts # Logging system +│ ├── constants.ts # Constants +│ ├── types.ts # TypeScript types +│ ├── router.ts # API routes +│ ├── server.ts # Server setup +│ └── index.ts # Entry point +├── public/ +│ └── oauth-success.html # OAuth success page +├── docker/ +│ ├── Dockerfile # Docker image +│ ├── docker-compose.yml # Docker Compose +│ └── .dockerignore # Docker ignore +├── data/ # Token storage +├── logs/ # Log files +├── package.json +├── tsconfig.json +└── README.md +``` + +## Development + +### Build + +```bash +npm run build +``` + +### Type Check + +```bash +npm run typecheck +``` + +### Lint + +```bash +npm run lint +``` + +## License + +MIT + +## Disclaimer + +This project is for **personal development use** with your own ChatGPT Plus/Pro subscription. For production or multi-user applications, use the OpenAI Platform API. + +Users are responsible for ensuring their usage complies with: +- OpenAI Terms of Use: https://openai.com/policies/terms-of-use/ +- OpenAI Usage Policies: https://openai.com/policies/usage-policies/ diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..c340ac6 --- /dev/null +++ b/STATUS.md @@ -0,0 +1,278 @@ +# ChatGPT Codex Router - 项目完成状态 + +## 项目概述 + +一个 OpenAI 兼容的 API 路由器,通过 OAuth 认证将请求转发到 ChatGPT 后端。 + +**位置**: `/home/mars/project/chatgpt-codex-router` +**状态**: ✅ 完成 +**版本**: 1.0.0 + +--- + +## ✅ 已完成功能 + +### 核心功能 +- [x] OpenAI 兼容 API (`/v1/chat/completions`, `/v1/responses`) +- [x] OAuth 认证(PKCE 流程) +- [x] **自动登录检查**(新增) +- [x] GPT-5.x 模型支持(6 种模型变体) +- [x] 流式传输(SSE 支持) +- [x] 自动 Token 刷新 +- [x] 详细日志系统 +- [x] Docker 支持 + +### 文件和文档 +- [x] plan.md - 详细实施计划 +- [x] README.md - 项目主文档 +- [x] AUTO_LOGIN.md - 自动登录功能说明 +- [x] QUICK_START.md - 快速开始指南 +- [x] STATUS.md - 项目状态(本文件) + +--- + +## 🚀 自动登录功能(新增) + +### 功能说明 +服务器启动时自动检查认证状态: +1. 检查是否存在 Token 文件 +2. 验证 Token 是否过期 +3. 如果未登录或 Token 过期,自动发起 OAuth 流程 + +### 启动行为 + +**首次启动(无 Token):** +``` +[WARN] No authentication token found. Initiating OAuth login... +[INFO] Starting OAuth flow with state: xxx +[INFO] Local OAuth server started on port 1455 +[INFO] OAuth login initiated. +[INFO] Please complete the OAuth flow in your browser. +[INFO] OAuth URL: https://auth.openai.com/oauth/authorize?... +[INFO] Browser should have opened automatically. +[INFO] Server started on http://0.0.0.0:3000 +``` + +**已有有效 Token:** +``` +[INFO] Authentication token found and valid. +[INFO] Server started on http://0.0.0.0:3000 +``` + +**Token 过期:** +``` +[WARN] Authentication token expired. Please login again. +[INFO] Starting OAuth flow with state: xxx +... +``` + +### 实现细节 + +**新增文件/修改:** +- `src/index.ts` - 添加了 `checkAuthAndAutoLogin()` 和 `initiateOAuthLogin()` 函数 +- `package.json` - 更新了 build 脚本,复制 oauth-success.html 到 dist/ + +**核心逻辑:** +```typescript +async function checkAuthAndAutoLogin(): Promise { + const tokenData = loadToken(); + + if (!tokenData) { + logWarn(null, "No authentication token found. Initiating OAuth login..."); + return await initiateOAuthLogin(); + } + + if (isTokenExpired(tokenData)) { + logWarn(null, "Authentication token expired. Please login again."); + return await initiateOAuthLogin(); + } + + logInfo(null, "Authentication token found and valid."); + return true; +} +``` + +--- + +## 📁 项目结构 + +``` +chatgpt-codex-router/ +├── src/ +│ ├── auth/ # OAuth 认证模块 +│ │ ├── oauth.ts # OAuth 流程 +│ │ ├── token-storage.ts # Token 存储 +│ │ ├── token-refresh.ts # Token 刷新 +│ │ ├── server.ts # OAuth 服务器 +│ │ └── browser.ts # 浏览器工具 +│ ├── request/ # 请求处理 +│ │ ├── model-map.ts # 模型映射 +│ │ ├── reasoning.ts # Reasoning 配置 +│ │ ├── transformer.ts # 请求转换 +│ │ ├── headers.ts # Header 生成 +│ │ └── validator.ts # 请求验证 +│ ├── response/ # 响应处理 +│ │ ├── sse-parser.ts # SSE 解析 +│ │ ├── converter.ts # 响应转换 +│ │ ├── chat-completions.ts # Chat Completions 格式 +│ │ └── handler.ts # 响应处理器 +│ ├── prompts/ # Codex 系统提示 +│ │ ├── gpt-5-1.md +│ │ ├── gpt-5-2.md +│ │ ├── gpt-5-1-codex.md +│ │ ├── gpt-5-1-codex-max.md +│ │ ├── gpt-5-1-codex-mini.md +│ │ ├── gpt-5-2-codex.md +│ │ └── index.ts +│ ├── config.ts # 配置管理 +│ ├── logger.ts # 日志系统 +│ ├── constants.ts # 常量定义 +│ ├── types.ts # TypeScript 类型 +│ ├── router.ts # API 路由 +│ ├── server.ts # 服务器配置 +│ └── index.ts # 入口(含自动登录) +├── public/ +│ └── oauth-success.html # OAuth 成功页面 +├── docker/ +│ ├── Dockerfile # Docker 镜像 +│ ├── docker-compose.yml # Docker Compose +│ └── .dockerignore +├── dist/ # 编译输出 +├── data/ # 数据目录(Token 存储) +├── logs/ # 日志目录 +├── plan.md # 实施计划 +├── README.md # 主文档 +├── AUTO_LOGIN.md # 自动登录说明 +├── QUICK_START.md # 快速开始 +├── STATUS.md # 本文件 +├── package.json # 项目配置 +├── tsconfig.json # TypeScript 配置 +└── .gitignore # Git 忽略规则 +``` + +--- + +## 🎯 支持的模型 + +### GPT-5.1 系列 +- `gpt-5.1` (none/low/medium/high) - 通用模型 +- `gpt-5.1-codex` (low/medium/high) - Codex 模型 +- `gpt-5.1-codex-max` (low/medium/high/xhigh) - Codex Max 模型 +- `gpt-5.1-codex-mini` (medium/high) - Codex Mini 模型 + +### GPT-5.2 系列 +- `gpt-5.2` (none/low/medium/high/xhigh) - 最新通用模型 +- `gpt-5.2-codex` (low/medium/high/xhigh) - 最新 Codex 模型 + +--- + +## 📝 快速使用 + +### 1. 启动服务器(自动登录) +```bash +cd /home/mars/project/chatgpt-codex-router +npm start +``` + +### 2. 完成 OAuth 登录 +- 浏览器会自动打开(如果可能) +- 登录 ChatGPT 账户 +- 授权应用 +- 看到"Authentication Successful"页面 + +### 3. 发送请求 +```bash +curl http://localhost:3000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-5.2-codex", + "messages": [{"role": "user", "content": "Hello!"}] + }' +``` + +--- + +## 🔧 配置 + +### 默认配置 +```json +{ + "server": { "port": 3000, "host": "0.0.0.0" }, + "oauth": { + "clientId": "app_EMoamEEZ73f0CkXaXp7hrann", + "redirectUri": "http://localhost:1455/auth/callback", + "localServerPort": 1455 + }, + "backend": { "url": "https://chatgpt.com/backend-api", "timeout": 120000 }, + "logging": { "level": "info", "dir": "./logs", "enableRequestLogging": false }, + "codex": { "mode": true, "defaultReasoningEffort": "medium", "defaultTextVerbosity": "medium" } +} +``` + +### 环境变量 +- `PORT` - 服务器端口(默认 3000) +- `LOG_LEVEL` - 日志级别(error/warn/info/debug) +- `ENABLE_REQUEST_LOGGING` - 启用请求日志(true/false) + +--- + +## 🐳 Docker 使用 + +```bash +# 构建镜像 +docker build -f docker/Dockerfile -t chatgpt-codex-router . + +# 运行容器 +docker-compose -f docker/docker-compose.yml up -d + +# 查看日志 +docker-compose -f docker/docker-compose.yml logs -f +``` + +--- + +## 📊 项目统计 + +- **TypeScript 文件**: 24 个 +- **总代码行数**: ~2000+ 行 +- **API 端点**: 4 个 +- **支持的模型**: 6 个系列 +- **依赖包**: 15 个 +- **文档文件**: 5 个 + +--- + +## ✅ 测试验证 + +已测试的功能: +- [x] TypeScript 编译通过 +- [x] 服务器启动成功 +- [x] 自动登录触发正常 +- [x] OAuth 服务器启动 +- [x] Token 存储和读取 +- [x] 请求转换和转发 +- [x] 响应格式转换 +- [x] 流式和非流式响应 + +--- + +## 📚 相关文档 + +- **完整计划**: `plan.md` +- **项目主文档**: `README.md` +- **自动登录详解**: `AUTO_LOGIN.md` +- **快速开始指南**: `QUICK_START.md` + +--- + +## 🎉 完成总结 + +所有计划的功能已实现,新增了自动登录检查功能。项目已经可以正常使用: + +1. ✅ 启动时自动检查登录状态 +2. ✅ 未登录时自动发起 OAuth 流程 +3. ✅ Token 过期时提示重新登录 +4. ✅ 所有 API 端点正常工作 +5. ✅ Docker 支持完整 + +**项目已交付!** 🚀 diff --git a/docker/.dockerignore b/docker/.dockerignore new file mode 100644 index 0000000..c8c93f6 --- /dev/null +++ b/docker/.dockerignore @@ -0,0 +1,59 @@ +node_modules +npm-debug.log +yarn-debug.log +yarn-error.log +pnpm-debug.log +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Build output +dist/ +build/ +*.tsbuildinfo + +# Test +test/ +vitest.config.ts +coverage/ +*.lcov + +# Environment variables +.env +.env.local +.env.*.local + +# Logs +logs/ +*.log + +# Data directory +data/ +*.json +!package.json +!package-lock.json +!tsconfig.json + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Git +.git +.gitignore + +# Documentation +README.md +CHANGELOG.md +LICENSE +plan.md +docs/ + +# Keep directory placeholders +!.gitkeep +!data/.gitkeep +!logs/.gitkeep diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..79d3021 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,32 @@ +FROM oven/bun:1.1-alpine + +WORKDIR /app + +# Install dependencies +COPY package.json package-lock.json ./ +RUN bun install --production + +# Copy source code +COPY src/ ./src/ +COPY public/ ./public/ +COPY tsconfig.json ./ + +# Build the project +RUN bun run build + +# Create data and logs directories +RUN mkdir -p /app/data /app/logs + +# Expose ports +EXPOSE 3000 1455 + +# Set environment variables +ENV PORT=3000 +ENV NODE_ENV=production + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD bun run -e "fetch('http://localhost:3000/health').then(r => process.exit(r.ok ? 0 : 1))" + +# Start the server +CMD ["bun", "run", "start"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..9b0ba10 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,32 @@ +version: '3.8' + +services: + chatgpt-codex-router: + build: + context: . + dockerfile: docker/Dockerfile + container_name: chatgpt-codex-router + ports: + - "3000:3000" + - "1455:1455" + volumes: + - ./data:/app/data + - ./logs:/app/logs + - ./config.json:/app/.chatgpt-codex-router/config.json:ro + environment: + - PORT=3000 + - LOG_LEVEL=info + - NODE_ENV=production + restart: unless-stopped + healthcheck: + test: ["CMD", "bun", "run", "-e", "fetch('http://localhost:3000/health').then(r => process.exit(r.ok ? 0 : 1))"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + networks: + - chatgpt-router-network + +networks: + chatgpt-router-network: + driver: bridge diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..85eb1be --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3204 @@ +{ + "name": "chatgpt-codex-router", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "chatgpt-codex-router", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@openauthjs/openauth": "^0.4.3", + "dotenv": "^16.4.5", + "hono": "^4.10.4" + }, + "devDependencies": { + "@types/node": "^24.6.2", + "@typescript-eslint/eslint-plugin": "^8.15.0", + "eslint": "^9.15.0", + "prettier": "^3.4.2", + "tsx": "^4.19.2", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@openauthjs/openauth": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@openauthjs/openauth/-/openauth-0.4.3.tgz", + "integrity": "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw==", + "dependencies": { + "@standard-schema/spec": "1.0.0-beta.3", + "aws4fetch": "1.0.20", + "jose": "5.9.6" + }, + "peerDependencies": { + "arctic": "^2.2.2", + "hono": "^4.0.0" + } + }, + "node_modules/@oslojs/asn1": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz", + "integrity": "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==", + "license": "MIT", + "dependencies": { + "@oslojs/binary": "1.0.0" + } + }, + "node_modules/@oslojs/binary": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@oslojs/binary/-/binary-1.0.0.tgz", + "integrity": "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==", + "license": "MIT" + }, + "node_modules/@oslojs/crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@oslojs/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==", + "license": "MIT", + "dependencies": { + "@oslojs/asn1": "1.0.0", + "@oslojs/binary": "1.0.0" + } + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", + "license": "MIT" + }, + "node_modules/@oslojs/jwt": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@oslojs/jwt/-/jwt-0.2.0.tgz", + "integrity": "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==", + "license": "MIT", + "dependencies": { + "@oslojs/encoding": "0.4.1" + } + }, + "node_modules/@oslojs/jwt/node_modules/@oslojs/encoding": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-0.4.1.tgz", + "integrity": "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0-beta.3.tgz", + "integrity": "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw==", + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz", + "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.52.0.tgz", + "integrity": "sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.52.0", + "@typescript-eslint/type-utils": "8.52.0", + "@typescript-eslint/utils": "8.52.0", + "@typescript-eslint/visitor-keys": "8.52.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.52.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.52.0.tgz", + "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.52.0", + "@typescript-eslint/types": "8.52.0", + "@typescript-eslint/typescript-estree": "8.52.0", + "@typescript-eslint/visitor-keys": "8.52.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.52.0.tgz", + "integrity": "sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.52.0", + "@typescript-eslint/types": "^8.52.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.52.0.tgz", + "integrity": "sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.52.0", + "@typescript-eslint/visitor-keys": "8.52.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.52.0.tgz", + "integrity": "sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.52.0.tgz", + "integrity": "sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.52.0", + "@typescript-eslint/typescript-estree": "8.52.0", + "@typescript-eslint/utils": "8.52.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.52.0.tgz", + "integrity": "sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.52.0.tgz", + "integrity": "sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.52.0", + "@typescript-eslint/tsconfig-utils": "8.52.0", + "@typescript-eslint/types": "8.52.0", + "@typescript-eslint/visitor-keys": "8.52.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.52.0.tgz", + "integrity": "sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.52.0", + "@typescript-eslint/types": "8.52.0", + "@typescript-eslint/typescript-estree": "8.52.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.52.0.tgz", + "integrity": "sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.52.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arctic": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/arctic/-/arctic-2.3.4.tgz", + "integrity": "sha512-+p30BOWsctZp+CVYCt7oAean/hWGW42sH5LAcRQX56ttEkFJWbzXBhmSpibbzwSJkRrotmsA+oAoJoVsU0f5xA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@oslojs/crypto": "1.0.1", + "@oslojs/encoding": "1.1.0", + "@oslojs/jwt": "0.2.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/aws4fetch": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.20.tgz", + "integrity": "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "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/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hono": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.3.tgz", + "integrity": "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..168b8dd --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "chatgpt-codex-router", + "version": "1.0.0", + "description": "OpenAI-compatible API router that forwards requests to ChatGPT backend with OAuth authentication", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "type": "module", + "license": "MIT", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc && mkdir -p dist/public && cp public/oauth-success.html dist/public/", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit", + "lint": "eslint src --ext .ts", + "test": "vitest run", + "test:watch": "vitest" + }, + "engines": { + "node": ">=20.0.0" + }, + "dependencies": { + "hono": "^4.10.4", + "@openauthjs/openauth": "^0.4.3", + "dotenv": "^16.4.5" + }, + "devDependencies": { + "@types/node": "^24.6.2", + "typescript": "^5.9.3", + "vitest": "^3.2.4", + "eslint": "^9.15.0", + "prettier": "^3.4.2", + "@typescript-eslint/eslint-plugin": "^8.15.0", + "tsx": "^4.19.2" + }, + "files": [ + "dist/", + "public/", + "docker/", + "README.md", + "LICENSE" + ] +} diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..0a512c9 --- /dev/null +++ b/plan.md @@ -0,0 +1,774 @@ +# chatgpt-codex-router 项目计划 + +## 项目概览 + +**项目目标:** 创建一个独立的 OpenAI 兼容 API 服务器,通过 OAuth 认证将请求转发到 ChatGPT 后端,支持流式传输和详细的日志记录。 + +**支持的模型(GPT-5.x 系列):** +- `gpt-5.1` (none/low/medium/high) +- `gpt-5.2` (none/low/medium/high/xhigh) +- `gpt-5.1-codex` (low/medium/high) +- `gpt-5.1-codex-max` (low/medium/high/xhigh) +- `gpt-5.1-codex-mini` (medium/high) +- `gpt-5.2-codex` (low/medium/high/xhigh) + +--- + +## 项目结构 + +``` +chatgpt-codex-router/ +├── src/ +│ ├── index.ts # 服务器入口 +│ ├── server.ts # Hono 服务器配置 +│ ├── router.ts # API 路由定义 +│ ├── config.ts # 配置管理 +│ ├── logger.ts # 日志系统 +│ ├── auth/ # 认证模块 +│ │ ├── oauth.ts # OAuth 流程逻辑 +│ │ ├── token-storage.ts # Token 本地 JSON 存储 +│ │ ├── token-refresh.ts # Token 刷新逻辑 +│ │ ├── server.ts # 本地 OAuth 回调服务器 +│ │ └── browser.ts # 浏览器打开工具 +│ ├── request/ # 请求处理 +│ │ ├── transformer.ts # 请求体转换 +│ │ ├── headers.ts # Header 生成 +│ │ ├── validator.ts # 请求验证 +│ │ ├── model-map.ts # 模型映射 +│ │ └── reasoning.ts # 推理参数配置 +│ ├── response/ # 响应处理 +│ │ ├── handler.ts # 响应处理器 +│ │ ├── sse-parser.ts # SSE 流解析 +│ │ ├── converter.ts # 响应格式转换 +│ │ └── chat-completions.ts # Chat Completions 格式转换器 +│ ├── prompts/ # 内置 Codex Prompts +│ │ ├── gpt-5-1.md # GPT-5.1 系统提示 +│ │ ├── gpt-5-2.md # GPT-5.2 系统提示 +│ │ ├── gpt-5-1-codex.md # GPT-5.1 Codex 系统提示 +│ │ ├── gpt-5-1-codex-max.md # GPT-5.1 Codex Max 系统提示 +│ │ ├── gpt-5-1-codex-mini.md # GPT-5.1 Codex Mini 系统提示 +│ │ ├── gpt-5-2-codex.md # GPT-5.2 Codex 系统提示 +│ │ └── index.ts # Prompt 加载器 +│ ├── constants.ts # 常量定义 +│ └── types.ts # TypeScript 类型定义 +├── public/ +│ └── oauth-success.html # OAuth 成功页面 +├── docker/ +│ ├── Dockerfile # Docker 镜像配置 +│ ├── docker-compose.yml # Docker Compose 配置 +│ └── .dockerignore # Docker 忽略文件 +├── logs/ # 日志输出目录(.gitignore) +├── data/ # 数据目录(.gitignore,存储 tokens) +├── package.json +├── tsconfig.json +├── .gitignore +└── README.md +``` + +--- + +## 核心功能模块详解 + +### 1. 认证模块 (`src/auth/`) + +#### 1.1 OAuth 流程 (`oauth.ts`) +**功能:** +- PKCE challenge 生成 +- 授权 URL 构建 +- Authorization code 交换为 tokens +- JWT 解析获取 account_id + +**关键常量:** +```typescript +CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" +AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize" +TOKEN_URL = "https://auth.openai.com/oauth/token" +REDIRECT_URI = "http://localhost:1455/auth/callback" +SCOPE = "openid profile email offline_access" +``` + +#### 1.2 Token 存储 (`token-storage.ts`) +**功能:** +- 读取/保存/删除 tokens +- 存储路径:`data/tokens.json` +- 存储格式: + ```json + { + "access_token": "...", + "refresh_token": "...", + "expires_at": 1234567890, + "account_id": "...", + "updated_at": 1234567890 + } + ``` + +#### 1.3 Token 刷新 (`token-refresh.ts`) +**功能:** +- 检查 token 是否过期(提前 5 分钟刷新) +- 自动刷新过期的 token +- 更新本地存储 +- 刷新失败时抛出错误 + +#### 1.4 本地 OAuth 服务器 (`server.ts`) +**功能:** +- 监听 `http://127.0.0.1:1455/auth/callback` +- 接收并验证 authorization code +- 返回 OAuth 成功页面 +- Polling 机制(最多等待 60 秒) + +#### 1.5 浏览器工具 (`browser.ts`) +**功能:** +- 跨平台浏览器打开(macOS/Linux/Windows) +- 静默失败(用户可手动复制 URL) + +--- + +### 2. 请求处理模块 (`src/request/`) + +#### 2.1 请求体转换 (`transformer.ts`) +**核心转换逻辑:** + +```typescript +// 原始请求 +{ + "model": "gpt-5.2-codex", + "messages": [...], + "stream": false, + "temperature": 0.7 +} + +// 转换后请求 +{ + "model": "gpt-5.2-codex", + "input": [...], // messages 转换为 input 格式 + "stream": true, // 强制 stream=true + "store": false, // 添加 store=false + "instructions": "...", // 添加 Codex 系统提示 + "reasoning": { + "effort": "high", + "summary": "auto" + }, + "text": { + "verbosity": "medium" + }, + "include": ["reasoning.encrypted_content"] +} +``` + +**转换步骤:** +1. 模型名称标准化(使用 model-map) +2. `messages` → `input` 格式转换 +3. 过滤 `item_reference` 和 IDs +4. 添加 `store: false`, `stream: true` +5. 添加 Codex 系统提示(从内置 prompts 加载) +6. 配置 reasoning 参数(根据模型类型) +7. 配置 text verbosity +8. 添加 include 参数 +9. 移除不支持的参数(`max_output_tokens`, `max_completion_tokens`) + +#### 2.2 模型映射 (`model-map.ts`) +**支持的模型:** +```typescript +const MODEL_MAP: Record = { + // GPT-5.1 + "gpt-5.1": "gpt-5.1", + "gpt-5.1-none": "gpt-5.1", + "gpt-5.1-low": "gpt-5.1", + "gpt-5.1-medium": "gpt-5.1", + "gpt-5.1-high": "gpt-5.1", + + // GPT-5.2 + "gpt-5.2": "gpt-5.2", + "gpt-5.2-none": "gpt-5.2", + "gpt-5.2-low": "gpt-5.2", + "gpt-5.2-medium": "gpt-5.2", + "gpt-5.2-high": "gpt-5.2", + "gpt-5.2-xhigh": "gpt-5.2", + + // GPT-5.1 Codex + "gpt-5.1-codex": "gpt-5.1-codex", + "gpt-5.1-codex-low": "gpt-5.1-codex", + "gpt-5.1-codex-medium": "gpt-5.1-codex", + "gpt-5.1-codex-high": "gpt-5.1-codex", + + // GPT-5.1 Codex Max + "gpt-5.1-codex-max": "gpt-5.1-codex-max", + "gpt-5.1-codex-max-low": "gpt-5.1-codex-max", + "gpt-5.1-codex-max-medium": "gpt-5.1-codex-max", + "gpt-5.1-codex-max-high": "gpt-5.1-codex-max", + "gpt-5.1-codex-max-xhigh": "gpt-5.1-codex-max", + + // GPT-5.1 Codex Mini + "gpt-5.1-codex-mini": "gpt-5.1-codex-mini", + "gpt-5.1-codex-mini-medium": "gpt-5.1-codex-mini", + "gpt-5.1-codex-mini-high": "gpt-5.1-codex-mini", + + // GPT-5.2 Codex + "gpt-5.2-codex": "gpt-5.2-codex", + "gpt-5.2-codex-low": "gpt-5.2-codex", + "gpt-5.2-codex-medium": "gpt-5.2-codex", + "gpt-5.2-codex-high": "gpt-5.2-codex", + "gpt-5.2-codex-xhigh": "gpt-5.2-codex" +}; +``` + +#### 2.3 Header 生成 (`headers.ts`) +**Headers 列表:** +```typescript +{ + "Authorization": `Bearer ${accessToken}`, + "chatgpt-account-id": accountId, + "OpenAI-Beta": "responses=experimental", + "originator": "codex_cli_rs", + "session_id": promptCacheKey, + "conversation_id": promptCacheKey, + "accept": "text/event-stream", + "content-type": "application/json" +} +``` + +#### 2.4 Reasoning 配置 (`reasoning.ts`) +**Reasoning effort 配置:** +- `gpt-5.2`, `gpt-5.1`: 支持 `none/low/medium/high`(默认 `none`) +- `gpt-5.2-codex`, `gpt-5.1-codex-max`: 支持 `low/medium/high/xhigh`(默认 `high`) +- `gpt-5.1-codex`: 支持 `low/medium/high`(默认 `medium`) +- `gpt-5.1-codex-mini`: 支持 `medium/high`(默认 `medium`) + +#### 2.5 请求验证 (`validator.ts`) +**验证内容:** +- `model` 参数是否存在且有效 +- `messages` 或 `input` 是否存在 +- 消息格式是否正确 +- 必需字段是否存在 + +--- + +### 3. 响应处理模块 (`src/response/`) + +#### 3.1 响应处理器 (`handler.ts`) +**处理流程:** + +```typescript +async function handleResponse(response: Response, isStreaming: boolean): Promise { + if (isStreaming) { + // 流式响应:直接转发 SSE + return forwardStream(response); + } else { + // 非流式:解析 SSE 并转换为 JSON + return parseSseToJson(response); + } +} +``` + +#### 3.2 SSE 解析器 (`sse-parser.ts`) +**解析逻辑:** +```typescript +function parseSseStream(sseText: string): Response | null { + const lines = sseText.split('\n'); + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = JSON.parse(line.substring(6)); + if (data.type === 'response.done' || data.type === 'response.completed') { + return data.response; + } + } + } + return null; +} +``` + +#### 3.3 响应格式转换 (`converter.ts` & `chat-completions.ts`) + +**ChatGPT Responses API 格式 → OpenAI Chat Completions 格式:** + +**原始格式(ChatGPT):** +```json +{ + "type": "response.done", + "response": { + "id": "resp_...", + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello, world!" + } + ] + } + ], + "usage": { + "input_tokens": 100, + "output_tokens": 50, + "total_tokens": 150 + } + } +} +``` + +**转换后格式(OpenAI):** +```json +{ + "id": "resp_...", + "object": "chat.completion", + "created": 1736153600, + "model": "gpt-5.2-codex", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello, world!" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150 + } +} +``` + +**流式响应格式转换:** + +**原始 SSE 事件(ChatGPT):** +``` +data: {"type": "response.output_item.add.delta", "delta": {"content": [{"type": "output_text", "text": "Hello"}]}} +data: {"type": "response.output_item.add.delta", "delta": {"content": [{"type": "output_text", "text": ", world!"}]}} +data: {"type": "response.done", "response": {...}} +``` + +**转换后 SSE 事件(OpenAI):** +``` +data: {"id": "...", "object": "chat.completion.chunk", "created": 1736153600, "model": "gpt-5.2-codex", "choices": [{"index": 0, "delta": {"content": "Hello"}, "finish_reason": null}]} +data: {"id": "...", "object": "chat.completion.chunk", "created": 1736153600, "model": "gpt-5.2-codex", "choices": [{"index": 0, "delta": {"content": ", world!"}, "finish_reason": null}]} +data: {"id": "...", "object": "chat.completion.chunk", "created": 1736153600, "model": "gpt-5.2-codex", "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]} +``` + +--- + +### 4. 内置 Prompts (`src/prompts/`) + +**文件结构:** +- `gpt-5-1.md` - GPT-5.1 通用模型系统提示 +- `gpt-5-2.md` - GPT-5.2 通用模型系统提示 +- `gpt-5-1-codex.md` - GPT-5.1 Codex 系统提示 +- `gpt-5-1-codex-max.md` - GPT-5.1 Codex Max 系统提示 +- `gpt-5-1-codex-mini.md` - GPT-5.1 Codex Mini 系统提示 +- `gpt-5-2-codex.md` - GPT-5.2 Codex 系统提示 + +**Prompt 加载器 (`index.ts`):** +```typescript +export function getPrompt(modelFamily: ModelFamily): string { + const promptFile = PROMPT_FILES[modelFamily]; + return readFileSync(join(__dirname, promptFile), 'utf-8'); +} +``` + +**Prompts 内容:** 需要从 Codex CLI GitHub 仓库下载最新的 prompts 并嵌入到项目中。 + +--- + +### 5. 日志系统 (`src/logger.ts`) + +**日志级别:** +- `ERROR` - 错误日志(始终记录) +- `WARN` - 警告日志(始终记录) +- `INFO` - 信息日志(通过 `LOG_LEVEL=info` 启用) +- `DEBUG` - 调试日志(通过 `LOG_LEVEL=debug` 启用) + +**日志配置:** +- 日志目录:`logs/` +- 日志文件格式:`{date}-{level}.log` +- 日志格式:`[timestamp] [level] [request-id] message` +- 控制台输出:带颜色和结构化数据 + +**日志内容:** +- 请求开始/结束 +- Token 刷新 +- 请求转换前后 +- 响应状态 +- 错误详情(请求/响应体) + +**示例日志:** +``` +[2025-01-06 10:30:00] [INFO] [req-001] POST /v1/chat/completions +[2025-01-06 10:30:00] [DEBUG] [req-001] Before transform: {"model": "gpt-5.2-codex", "messages": [...]} +[2025-01-06 10:30:00] [DEBUG] [req-001] After transform: {"model": "gpt-5.2-codex", "input": [...], "stream": true, "store": false} +[2025-01-06 10:30:01] [INFO] [req-001] Response: 200 OK, 150 tokens +[2025-01-06 10:30:01] [INFO] [req-001] Request completed in 1234ms +``` + +--- + +### 6. API 端点 + +#### 6.1 `POST /v1/chat/completions` +**请求:** OpenAI Chat Completions 格式 +**响应:** OpenAI Chat Completions 格式(转换后) + +**流程:** +1. 验证请求体 +2. 检查 token,自动刷新 +3. 转换请求体(messages → input) +4. 生成 Headers +5. 转发到 `https://chatgpt.com/backend-api/codex/responses` +6. 处理响应(流式/非流式) +7. 转换响应格式(ChatGPT → OpenAI) + +#### 6.2 `POST /v1/responses` +**请求:** OpenAI Responses API 格式 +**响应:** OpenAI Responses API 格式(部分转换) + +**流程:** +1. 验证请求体 +2. 检查并刷新 token +3. 转换请求体(添加必需字段) +4. 生成 Headers +5. 转发到 `https://chatgpt.com/backend-api/codex/responses` +6. 处理响应(流式/非流式) +7. 返回转换后的 Responses API 格式 + +#### 6.3 `POST /auth/login` +**请求:** 无需参数 +**响应:** 授权 URL 和说明 + +**流程:** +1. 生成 PKCE challenge 和 state +2. 构建授权 URL +3. 启动本地 OAuth 服务器 +4. 尝试打开浏览器 +5. 返回授权信息 + +#### 6.4 `POST /auth/callback` +**内部端点:** 仅用于本地 OAuth 服务器 +- 接收 authorization code +- 验证 state +- 交换 tokens +- 保存到 `data/tokens.json` +- 返回成功页面 + +--- + +### 7. 配置管理 (`src/config.ts`) + +**配置文件:** `~/.chatgpt-codex-router/config.json` + +**默认配置:** +```json +{ + "server": { + "port": 3000, + "host": "0.0.0.0" + }, + "oauth": { + "clientId": "app_EMoamEEZ73f0CkXaXp7hrann", + "redirectUri": "http://localhost:1455/auth/callback", + "localServerPort": 1455 + }, + "backend": { + "url": "https://chatgpt.com/backend-api", + "timeout": 120000 + }, + "logging": { + "level": "info", + "dir": "logs", + "enableRequestLogging": false + }, + "codex": { + "mode": true, + "defaultReasoningEffort": "medium", + "defaultTextVerbosity": "medium" + } +} +``` + +**环境变量:** +- `PORT` - 服务器端口(默认 3000) +- `CONFIG_PATH` - 配置文件路径 +- `LOG_LEVEL` - 日志级别(error/warn/info/debug) +- `ENABLE_REQUEST_LOGGING` - 启用请求日志(true/false) + +--- + +### 8. Docker 支持 (`docker/`) + +#### 8.1 Dockerfile +```dockerfile +FROM node:20-alpine + +WORKDIR /app + +# 安装依赖 +COPY package.json package-lock.json ./ +RUN npm ci --only=production + +# 复制源代码 +COPY src/ ./src/ +COPY public/ ./public/ +COPY tsconfig.json ./ + +# 构建项目 +RUN npm run build + +# 创建数据和日志目录 +RUN mkdir -p /app/data /app/logs + +# 暴露端口 +EXPOSE 3000 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" + +# 启动服务 +CMD ["npm", "start"] +``` + +#### 8.2 docker-compose.yml +```yaml +version: '3.8' + +services: + chatgpt-codex-router: + build: . + container_name: chatgpt-codex-router + ports: + - "3000:3000" + - "1455:1455" + volumes: + - ./data:/app/data + - ./logs:/app/logs + - ./config.json:/app/.chatgpt-codex-router/config.json:ro + environment: + - PORT=3000 + - LOG_LEVEL=info + restart: unless-stopped + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s +``` + +#### 8.3 .dockerignore +``` +node_modules +npm-debug.log +.git +.gitignore +README.md +.dockerignore +Dockerfile +docker-compose.yml +test +vitest.config.ts +logs/* +data/* +!data/.gitkeep +``` + +--- + +## 开发步骤 + +### 阶段 1:项目初始化(第 1-2 天) +- [ ] 创建项目结构 +- [ ] 配置 TypeScript +- [ ] 配置 package.json(依赖、脚本) +- [ ] 创建 .gitignore +- [ ] 设置开发环境(ESLint、Prettier) + +### 阶段 2:基础设施(第 3-4 天) +- [ ] 实现日志系统 (`logger.ts`) +- [ ] 实现配置管理 (`config.ts`) +- [ ] 实现常量定义 (`constants.ts`) +- [ ] 实现类型定义 (`types.ts`) +- [ ] 创建基础服务器框架 (`server.ts`, `index.ts`) + +### 阶段 3:认证模块(第 5-7 天) +- [ ] 实现 OAuth 流程 (`oauth.ts`) +- [ ] 实现 Token 存储 (`token-storage.ts`) +- [ ] 实现 Token 刷新 (`token-refresh.ts`) +- [ ] 实现本地 OAuth 服务器 (`server.ts`) +- [ ] 实现浏览器工具 (`browser.ts`) +- [ ] 创建 OAuth 成功页面 (`public/oauth-success.html`) + +### 阶段 4:请求处理(第 8-10 天) +- [ ] 实现模型映射 (`model-map.ts`) +- [ ] 实现 Reasoning 配置 (`reasoning.ts`) +- [ ] 实现请求体转换 (`transformer.ts`) +- [ ] 实现 Header 生成 (`headers.ts`) +- [ ] 实现请求验证 (`validator.ts`) +- [ ] 从 GitHub 下载并内置 Codex prompts + +### 阶段 5:响应处理(第 11-13 天) +- [ ] 实现 SSE 解析器 (`sse-parser.ts`) +- [ ] 实现响应格式转换 (`converter.ts`) +- [ ] 实现 Chat Completions 格式转换器 (`chat-completions.ts`) +- [ ] 实现响应处理器 (`handler.ts`) +- [ ] 实现流式/非流式响应处理 + +### 阶段 6:API 端点(第 14-16 天) +- [ ] 实现 `/v1/chat/completions` +- [ ] 实现 `/v1/responses` +- [ ] 实现 `/auth/login` +- [ ] 实现 `/health` 健康检查端点 +- [ ] 测试所有端点 + +### 阶段 7:Docker 支持(第 17-18 天) +- [ ] 创建 Dockerfile +- [ ] 创建 docker-compose.yml +- [ ] 创建 .dockerignore +- [ ] 测试 Docker 构建 +- [ ] 编写 Docker 使用文档 + +### 阶段 8:测试和优化(第 19-21 天) +- [ ] 单元测试 +- [ ] 集成测试 +- [ ] 性能测试 +- [ ] 日志优化 +- [ ] 错误处理优化 + +### 阶段 9:文档和发布(第 22-23 天) +- [ ] 编写 README +- [ ] 编写 API 文档 +- [ ] 编写 Docker 文档 +- [ ] 准备 NPM 发布 + +--- + +## 依赖项 + +### 生产依赖 +```json +{ + "hono": "^4.10.4", + "@openauthjs/openauth": "^0.4.3", + "dotenv": "^16.4.5" +} +``` + +### 开发依赖 +```json +{ + "@types/node": "^24.6.2", + "typescript": "^5.9.3", + "vitest": "^3.2.4", + "eslint": "^9.15.0", + "prettier": "^3.4.2", + "@typescript-eslint/eslint-plugin": "^8.15.0" +} +``` + +--- + +## 关键实现细节 + +### 1. Messages → Input 转换 + +**OpenAI Chat Completions 格式:** +```json +{ + "messages": [ + {"role": "user", "content": "Hello"} + ] +} +``` + +**ChatGPT Responses API 格式:** +```json +{ + "input": [ + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Hello"}]} + ] +} +``` + +**转换逻辑:** +```typescript +function messagesToInput(messages: Message[]): InputItem[] { + return messages.map(msg => ({ + type: "message", + role: msg.role, + content: Array.isArray(msg.content) + ? msg.content.map(c => ({ type: "input_text", text: c.text })) + : [{ type: "input_text", text: msg.content }] + })); +} +``` + +### 2. 流式 SSE 转换 + +**转换逻辑:** +```typescript +async function transformSseStream( + reader: ReadableStreamDefaultReader, + model: string +): ReadableStream { + const encoder = new TextEncoder(); + + return new ReadableStream({ + async start(controller) { + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = JSON.parse(line.substring(6)); + const transformed = transformChunk(data, model); + controller.enqueue(encoder.encode(`data: ${JSON.stringify(transformed)}\n\n`)); + } + } + } + + controller.close(); + } + }); +} +``` + +### 3. Token 刷新时机 + +**刷新策略:** +- 提前 5 分钟刷新(`expires_at - 300000 < Date.now()`) +- 每次请求前检查 +- 刷新失败时返回 401 +- 刷新成功后更新本地存储 + +--- + +## 测试计划 + +### 单元测试 +- [ ] OAuth 流程测试 +- [ ] Token 存储测试 +- [ ] 请求转换测试 +- [ ] 响应转换测试 +- [ ] SSE 解析测试 + +### 集成测试 +- [ ] 完整 OAuth 流程测试 +- [ ] Chat Completions 端点测试(流式/非流式) +- [ ] Responses API 端点测试(流式/非流式) +- [ ] Token 自动刷新测试 +- [ ] 错误处理测试 + +### 手动测试 +- [ ] 使用 curl 测试所有端点 +- [ ] 使用 OpenAI SDK 测试兼容性 +- [ ] 使用不同模型测试 +- [ ] Docker 部署测试 diff --git a/public/oauth-success.html b/public/oauth-success.html new file mode 100644 index 0000000..45dec8a --- /dev/null +++ b/public/oauth-success.html @@ -0,0 +1,81 @@ + + + + + + Authentication Successful + + + +
+
+ + + +
+

Authentication Successful

+

You have successfully authenticated with ChatGPT. You can now close this window.

+ +
+ + diff --git a/src/auth/browser.ts b/src/auth/browser.ts new file mode 100644 index 0000000..32be37d --- /dev/null +++ b/src/auth/browser.ts @@ -0,0 +1,70 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { PLATFORM_OPENERS } from "../constants.js"; +import { logDebug, logWarn } from "../logger.js"; + +function getBrowserOpener(): string { + const platform = process.platform; + if (platform === "darwin") return PLATFORM_OPENERS.darwin; + if (platform === "win32") return PLATFORM_OPENERS.win32; + return PLATFORM_OPENERS.linux; +} + +function commandExists(command: string): boolean { + if (!command) return false; + + if (process.platform === "win32" && command.toLowerCase() === "start") { + return true; + } + + const pathValue = process.env.PATH || ""; + const entries = pathValue.split(process.platform === "win32" ? ";" : ":").filter(Boolean); + + if (entries.length === 0) return false; + + if (process.platform === "win32") { + const pathext = (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";"); + for (const entry of entries) { + for (const ext of pathext) { + const candidate = join(entry, `${command}${ext}`); + if (existsSync(candidate)) return true; + } + } + return false; + } + + for (const entry of entries) { + const candidate = join(entry, command); + if (existsSync(candidate)) return true; + } + return false; +} + +export function openBrowser(url: string): boolean { + try { + const opener = getBrowserOpener(); + + if (!commandExists(opener)) { + logWarn(null, `Browser opener not found: ${opener}`); + return false; + } + + logDebug(null, `Opening browser: ${opener} ${url}`); + + const child = spawn(opener, [url], { + stdio: "ignore", + shell: process.platform === "win32", + }); + + child.on("error", () => { + logWarn(null, "Failed to open browser"); + }); + + return true; + } catch (error) { + const err = error as Error; + logWarn(null, `Browser open error: ${err.message}`); + return false; + } +} diff --git a/src/auth/oauth.ts b/src/auth/oauth.ts new file mode 100644 index 0000000..9c492f7 --- /dev/null +++ b/src/auth/oauth.ts @@ -0,0 +1,192 @@ +import { randomBytes, createHash } from "node:crypto"; +import { logError, logInfo, logDebug } from "../logger.js"; + +export interface PKCEPair { + codeVerifier: string; + codeChallenge: string; +} + +export interface OAuthAuthorizationFlow { + pkce: PKCEPair; + state: string; + url: string; +} + +export interface TokenExchangeResult { + success: boolean; + access_token?: string; + refresh_token?: string; + expires_in?: number; + error?: string; +} + +export interface JWTPayload { + "https://api.openai.com/auth"?: { + chatgpt_account_id?: string; + }; + [key: string]: unknown; +} + +export async function generatePKCE(): Promise { + const codeVerifier = randomBytes(32).toString("base64url"); + + const hash = createHash("sha256"); + hash.update(codeVerifier); + const codeChallenge = hash.digest("base64url"); + + logDebug(null, "Generated PKCE challenge"); + + return { codeVerifier, codeChallenge }; +} + +export function generateState(): string { + return randomBytes(16).toString("hex"); +} + +export function decodeJWT(token: string): JWTPayload | null { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + + const payload = parts[1]; + const decoded = Buffer.from(payload, "base64").toString("utf-8"); + return JSON.parse(decoded) as JWTPayload; + } catch (error) { + logError(null, "Failed to decode JWT", error); + return null; + } +} + +export async function exchangeAuthorizationCode( + code: string, + codeVerifier: string, + clientId: string, + redirectUri: string, +): Promise { + try { + logDebug(null, "Exchanging authorization code for tokens"); + + const response = await fetch("https://auth.openai.com/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: clientId, + code, + code_verifier: codeVerifier, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + logError(null, `Token exchange failed: ${response.status}`, text); + return { success: false, error: "Authorization failed" }; + } + + const data = (await response.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + + if (!data.access_token || !data.refresh_token || !data.expires_in) { + logError(null, "Token response missing required fields", data); + return { success: false, error: "Invalid token response" }; + } + + logInfo(null, "Successfully exchanged authorization code"); + + return { + success: true, + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_in: data.expires_in, + }; + } catch (error) { + const err = error as Error; + logError(null, "Token exchange error", err.message); + return { success: false, error: err.message }; + } +} + +export async function refreshAccessToken( + refreshToken: string, + clientId: string, +): Promise { + try { + logDebug(null, "Refreshing access token"); + + const response = await fetch("https://auth.openai.com/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + }), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + logError(null, `Token refresh failed: ${response.status}`, text); + return { success: false, error: "Refresh failed" }; + } + + const data = (await response.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + + if (!data.access_token || !data.refresh_token || !data.expires_in) { + logError(null, "Refresh response missing required fields", data); + return { success: false, error: "Invalid refresh response" }; + } + + logInfo(null, "Successfully refreshed access token"); + + return { + success: true, + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_in: data.expires_in, + }; + } catch (error) { + const err = error as Error; + logError(null, "Token refresh error", err.message); + return { success: false, error: err.message }; + } +} + +export async function createOAuthFlow( + clientId: string, + redirectUri: string, +): Promise { + const pkce = await generatePKCE(); + const state = generateState(); + + const url = new URL("https://auth.openai.com/oauth/authorize"); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("scope", "openid profile email offline_access"); + url.searchParams.set("code_challenge", pkce.codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", state); + url.searchParams.set("id_token_add_organizations", "true"); + url.searchParams.set("codex_cli_simplified_flow", "true"); + url.searchParams.set("originator", "codex_cli_rs"); + + logDebug(null, `Created OAuth flow with state: ${state}`); + + return { + pkce, + state, + url: url.toString(), + }; +} diff --git a/src/auth/server.ts b/src/auth/server.ts new file mode 100644 index 0000000..eeae8c2 --- /dev/null +++ b/src/auth/server.ts @@ -0,0 +1,198 @@ +import { createServer, IncomingMessage, ServerResponse, Server as HTTPServer } from "node:http"; +import { readFileSync, existsSync } from "node:fs"; +import { join, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { logDebug, logError, logInfo } from "../logger.js"; +import type { PKCEPair } from "./oauth.js"; +import { exchangeAuthorizationCode, decodeJWT } from "./oauth.js"; +import { saveToken } from "./token-storage.js"; +import { getConfig } from "../config.js"; +import type { TokenData } from "../types.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +let successHtml: string; +try { + let htmlPath: string; + + if (existsSync(join(__dirname, "..", "public", "oauth-success.html"))) { + htmlPath = join(__dirname, "..", "public", "oauth-success.html"); + } else if (existsSync(join(__dirname, "public", "oauth-success.html"))) { + htmlPath = join(__dirname, "public", "oauth-success.html"); + } else if (existsSync(join(process.cwd(), "public", "oauth-success.html"))) { + htmlPath = join(process.cwd(), "public", "oauth-success.html"); + } else { + throw new Error("Cannot find oauth-success.html"); + } + + successHtml = readFileSync(htmlPath, "utf-8"); + logInfo(null, `Loaded oauth-success.html from ${htmlPath}`); +} catch (error) { + const err = error as Error; + logError(null, `Failed to load oauth-success.html: ${err.message}`); + successHtml = ` + + + Authentication Successful + +

Authentication Successful

+

You can now close this window.

+ + + `; +} + +export interface OAuthServerInfo { + port: number; + ready: boolean; + close: () => void; + waitForCode: (state: string) => Promise<{ code: string } | null>; +} + +export interface OAuthServerOptions { + state: string; + pkce: PKCEPair; + port?: number; +} + +export async function startLocalOAuthServer( + options: OAuthServerOptions, +): Promise { + const port = options.port || 1455; + const { state, pkce } = options; + let lastCode: string | null = null; + let server: HTTPServer | null = null; + + const serverPromise = new Promise((resolve) => { + const requestHandler = async ( + req: IncomingMessage, + res: ServerResponse, + ) => { + try { + const url = new URL(req.url || "", `http://localhost:${port}`); + + if (url.pathname !== "/auth/callback") { + res.statusCode = 404; + res.end("Not Found"); + return; + } + + const stateParam = url.searchParams.get("state"); + if (stateParam !== state) { + logError(null, "State mismatch in OAuth callback"); + res.statusCode = 400; + res.end("State mismatch"); + return; + } + + const code = url.searchParams.get("code"); + if (!code) { + logError(null, "Missing authorization code in OAuth callback"); + res.statusCode = 400; + res.end("Missing authorization code"); + return; + } + + lastCode = code; + logInfo(null, "Received authorization code via callback"); + + const config = getConfig(); + const result = await exchangeAuthorizationCode( + code, + pkce.codeVerifier, + config.oauth.clientId, + config.oauth.redirectUri, + ); + + if (result.success && result.access_token) { + const decoded = decodeJWT(result.access_token); + const accountId = + decoded?.["https://api.openai.com/auth"]?.chatgpt_account_id; + + if (accountId && result.refresh_token && result.expires_in) { + const tokenData: TokenData = { + access_token: result.access_token, + refresh_token: result.refresh_token, + expires_at: Date.now() + result.expires_in * 1000, + account_id: accountId, + updated_at: Date.now(), + }; + + saveToken(tokenData); + logInfo(null, "Successfully saved token from OAuth callback"); + } + } else { + logError(null, "Failed to exchange authorization code"); + } + + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(successHtml); + } catch (error) { + const err = error as Error; + logError(null, `OAuth server error: ${err.message}`); + res.statusCode = 500; + res.end("Internal Error"); + } + }; + + server = createServer(requestHandler); + + server.on("error", (err: NodeJS.ErrnoException) => { + logError(null, `Failed to start OAuth server on port ${port}: ${err.message}`); + resolve({ + port, + ready: false, + close: () => { + if (server) { + server.close(); + server = null; + } + }, + waitForCode: async () => { + logError(null, "Server not ready, cannot wait for code"); + return null; + }, + }); + }); + + server.listen(port, "127.0.0.1", () => { + logInfo(null, `Local OAuth server started on port ${port}`); + resolve({ + port, + ready: true, + close: () => { + if (server) { + server.close(); + server = null; + } + }, + waitForCode: async (expectedState: string) => { + if (expectedState !== state) { + logError(null, "State mismatch in waitForCode"); + return null; + } + + const pollDelay = 100; + const maxPolls = 600; + let polls = 0; + + while (polls < maxPolls) { + if (lastCode !== null) { + return { code: lastCode }; + } + + await new Promise((resolve) => setTimeout(resolve, pollDelay)); + polls++; + } + + logError(null, "Timeout waiting for authorization code"); + return null; + }, + }); + }); + }); + + return serverPromise; +} diff --git a/src/auth/token-refresh.ts b/src/auth/token-refresh.ts new file mode 100644 index 0000000..48f6590 --- /dev/null +++ b/src/auth/token-refresh.ts @@ -0,0 +1,93 @@ +import { loadToken, saveToken, isTokenExpired } from "./token-storage.js"; +import { refreshAccessToken, decodeJWT } from "./oauth.js"; +import { getConfig } from "../config.js"; +import { TokenData } from "../types.js"; +import { logError, logInfo, logDebug } from "../logger.js"; + +export async function ensureValidToken(): Promise { + const config = getConfig(); + let tokenData = loadToken(); + + if (!tokenData) { + logInfo(null, "No token found, user needs to login"); + return null; + } + + if (isTokenExpired(tokenData)) { + logInfo(null, "Token expired, attempting to refresh"); + const refreshed = await refreshToken(tokenData); + + if (!refreshed) { + logError(null, "Failed to refresh token"); + return null; + } + + tokenData = refreshed; + } + + return tokenData; +} + +export async function refreshToken( + tokenData: TokenData, +): Promise { + try { + const config = getConfig(); + const result = await refreshAccessToken( + tokenData.refresh_token, + config.oauth.clientId, + ); + + if (!result.success || !result.access_token || !result.refresh_token) { + logError(null, "Token refresh failed"); + return null; + } + + const decoded = decodeJWT(result.access_token); + const accountId = + decoded?.["https://api.openai.com/auth"]?.chatgpt_account_id; + + if (!accountId) { + logError(null, "Failed to extract account_id from new token"); + return null; + } + + const newTokenData: TokenData = { + access_token: result.access_token, + refresh_token: result.refresh_token, + expires_at: Date.now() + result.expires_in! * 1000, + account_id: accountId, + updated_at: Date.now(), + }; + + saveToken(newTokenData); + + logInfo(null, "Successfully refreshed token"); + + return newTokenData; + } catch (error) { + const err = error as Error; + logError(null, `Token refresh error: ${err.message}`); + return null; + } +} + +export async function getAccessToken(): Promise { + const tokenData = await ensureValidToken(); + + if (!tokenData) { + return null; + } + + return tokenData.access_token; +} + +export async function getAccountId(): Promise { + const tokenData = await ensureValidToken(); + + if (!tokenData) { + return null; + } + + return tokenData.account_id; +} diff --git a/src/auth/token-storage.ts b/src/auth/token-storage.ts new file mode 100644 index 0000000..231a88b --- /dev/null +++ b/src/auth/token-storage.ts @@ -0,0 +1,78 @@ +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; +import { TokenData } from "../types.js"; +import { logError, logInfo, logDebug } from "../logger.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function getDataDir(): string { + return process.env.DATA_DIR || join(homedir(), ".chatgpt-codex-router"); +} + +function getTokenPath(): string { + return join(getDataDir(), "tokens.json"); +} + +export function loadToken(): TokenData | null { + try { + const tokenPath = getTokenPath(); + if (!existsSync(tokenPath)) { + logDebug(null, "No token file found"); + return null; + } + + const fileContent = readFileSync(tokenPath, "utf-8"); + const tokenData = JSON.parse(fileContent) as TokenData; + + logDebug(null, "Loaded token from storage"); + + return tokenData; + } catch (error) { + const err = error as Error; + logError(null, `Failed to load token: ${err.message}`); + return null; + } +} + +export function saveToken(tokenData: TokenData): void { + try { + const dataDir = getDataDir(); + if (!existsSync(dataDir)) { + mkdirSync(dataDir, { recursive: true }); + } + + const tokenPath = getTokenPath(); + writeFileSync(tokenPath, JSON.stringify(tokenData, null, 2), "utf-8"); + + logInfo(null, "Saved token to storage"); + } catch (error) { + const err = error as Error; + logError(null, `Failed to save token: ${err.message}`); + throw err; + } +} + +export function deleteToken(): void { + try { + const tokenPath = getTokenPath(); + if (existsSync(tokenPath)) { + const fs = require("node:fs"); + fs.unlinkSync(tokenPath); + logInfo(null, "Deleted token from storage"); + } + } catch (error) { + const err = error as Error; + logError(null, `Failed to delete token: ${err.message}`); + throw err; + } +} + +export function isTokenExpired(tokenData: TokenData, bufferMs: number = 300000): boolean { + return tokenData.expires_at - bufferMs < Date.now(); +} + +export function getTokenTTL(tokenData: TokenData): number { + return Math.max(0, tokenData.expires_at - Date.now()); +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..31be8f7 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,142 @@ +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; +import { AppConfig, ReasoningEffort } from "./types.js"; +import { logWarn, logInfo } from "./logger.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const DEFAULT_CONFIG: AppConfig = { + server: { + port: 3000, + host: "0.0.0.0", + }, + oauth: { + clientId: "app_EMoamEEZ73f0CkXaXp7hrann", + redirectUri: "http://localhost:1455/auth/callback", + localServerPort: 1455, + }, + backend: { + url: "https://chatgpt.com/backend-api", + timeout: 120000, + }, + logging: { + level: "info", + dir: join(__dirname, "..", "logs"), + enableRequestLogging: false, + }, + codex: { + mode: true, + defaultReasoningEffort: "medium" as ReasoningEffort, + defaultTextVerbosity: "medium", + }, +}; + +let cachedConfig: AppConfig | null = null; + +function getConfigPath(): string { + const configPath = process.env.CONFIG_PATH; + if (configPath) { + return configPath; + } + + const configDir = join(homedir(), ".chatgpt-codex-router"); + return join(configDir, "config.json"); +} + +function loadConfigFile(): Partial { + try { + const configPath = getConfigPath(); + if (!existsSync(configPath)) { + logInfo(null, `Config file not found at ${configPath}, using defaults`); + return {}; + } + + const fileContent = readFileSync(configPath, "utf-8"); + const userConfig = JSON.parse(fileContent) as Partial; + + logInfo(null, `Loaded config from ${configPath}`); + return userConfig; + } catch (error) { + const err = error as Error; + logWarn(null, `Failed to load config file: ${err.message}, using defaults`); + return {}; + } +} + +export function loadConfig(): AppConfig { + if (cachedConfig) { + return cachedConfig; + } + + const envConfig: Partial = {}; + + if (process.env.PORT) { + envConfig.server = { + port: parseInt(process.env.PORT, 10), + host: DEFAULT_CONFIG.server.host, + }; + } + + if (process.env.LOG_LEVEL) { + const level = process.env.LOG_LEVEL.toLowerCase(); + if (["error", "warn", "info", "debug"].includes(level)) { + envConfig.logging = { + ...DEFAULT_CONFIG.logging, + level: level as AppConfig["logging"]["level"], + }; + } + } + + if (process.env.ENABLE_REQUEST_LOGGING === "1") { + envConfig.logging = { + ...DEFAULT_CONFIG.logging, + enableRequestLogging: true, + }; + } + + const fileConfig = loadConfigFile(); + + cachedConfig = { + ...DEFAULT_CONFIG, + ...fileConfig, + ...envConfig, + server: { + ...DEFAULT_CONFIG.server, + ...fileConfig.server, + ...envConfig.server, + }, + oauth: { + ...DEFAULT_CONFIG.oauth, + ...fileConfig.oauth, + }, + backend: { + ...DEFAULT_CONFIG.backend, + ...fileConfig.backend, + }, + logging: { + ...DEFAULT_CONFIG.logging, + ...fileConfig.logging, + ...envConfig.logging, + }, + codex: { + ...DEFAULT_CONFIG.codex, + ...fileConfig.codex, + }, + }; + + return cachedConfig; +} + +export function getConfig(): AppConfig { + if (!cachedConfig) { + return loadConfig(); + } + return cachedConfig; +} + +export function reloadConfig(): AppConfig { + cachedConfig = null; + return loadConfig(); +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..4771d5c --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,76 @@ +export const PLUGIN_NAME = "chatgpt-codex-router"; + +export const HTTP_STATUS = { + OK: 200, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + NOT_FOUND: 404, + TOO_MANY_REQUESTS: 429, + INTERNAL_SERVER_ERROR: 500, +} as const; + +export const OPENAI_HEADERS = { + AUTHORIZATION: "Authorization", + ACCOUNT_ID: "chatgpt-account-id", + BETA: "OpenAI-Beta", + ORIGINATOR: "originator", + SESSION_ID: "session_id", + CONVERSATION_ID: "conversation_id", + ACCEPT: "accept", + CONTENT_TYPE: "content-type", +} as const; + +export const OPENAI_HEADER_VALUES = { + BETA_RESPONSES: "responses=experimental", + ORIGINATOR_CODEX: "codex_cli_rs", + ACCEPT_STREAM: "text/event-stream", + CONTENT_TYPE_JSON: "application/json", +} as const; + +export const URL_PATHS = { + CHAT_COMPLETIONS: "/v1/chat/completions", + RESPONSES: "/v1/responses", + CODEX_RESPONSES: "/codex/responses", + AUTH_LOGIN: "/auth/login", + AUTH_CALLBACK: "/auth/callback", + HEALTH: "/health", +} as const; + +export const ERROR_MESSAGES = { + NO_TOKEN: "No authentication token found. Please login first.", + TOKEN_EXPIRED: "Authentication token expired. Please login again.", + TOKEN_REFRESH_FAILED: "Failed to refresh authentication token.", + REQUEST_PARSE_ERROR: "Error parsing request body", + INVALID_MODEL: "Invalid or unsupported model", + MISSING_MESSAGES: "Missing required field: messages", + UNAUTHORIZED: "Unauthorized access", + RATE_LIMIT_EXCEEDED: "Rate limit exceeded", +} as const; + +export const AUTH_LABELS = { + OAUTH: "ChatGPT Plus/Pro (OAuth)", + INSTRUCTIONS: "Please complete the OAuth authentication in your browser", + INSTRUCTIONS_MANUAL: "If the browser does not open automatically, please copy the URL and open it manually", +} as const; + +export const PLATFORM_OPENERS = { + darwin: "open", + win32: "start", + linux: "xdg-open", +} as const; + +export const LOG_LEVELS = { + ERROR: "error", + WARN: "warn", + INFO: "info", + DEBUG: "debug", +} as const; + +export const MODEL_FAMILY_PROMPTS: Record = { + "gpt-5.1": "gpt-5-1.md", + "gpt-5.2": "gpt-5-2.md", + "gpt-5.1-codex": "gpt-5-1-codex.md", + "gpt-5.1-codex-max": "gpt-5-1-codex-max.md", + "gpt-5.1-codex-mini": "gpt-5-1-codex-mini.md", + "gpt-5.2-codex": "gpt-5-2-codex.md", +} as const; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9345275 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,156 @@ +import { createServer, IncomingMessage, ServerResponse } from "node:http"; +import { createApp } from "./server.js"; +import { getConfig } from "./config.js"; +import { logInfo, logError, logWarn } from "./logger.js"; +import { loadToken, isTokenExpired } from "./auth/token-storage.js"; +import { startLocalOAuthServer } from "./auth/server.js"; +import { createOAuthFlow } from "./auth/oauth.js"; +import { openBrowser } from "./auth/browser.js"; + +async function checkAuthAndAutoLogin(): Promise { + const tokenData = loadToken(); + + if (!tokenData) { + logWarn(null, "No authentication token found. Initiating OAuth login..."); + return await initiateOAuthLogin(); + } + + if (isTokenExpired(tokenData)) { + logWarn(null, "Authentication token expired. Please login again."); + return await initiateOAuthLogin(); + } + + logInfo(null, "Authentication token found and valid."); + return true; +} + +async function initiateOAuthLogin(): Promise { + try { + const config = getConfig(); + const oauthFlow = await createOAuthFlow(config.oauth.clientId, config.oauth.redirectUri); + + logInfo(null, `Starting OAuth flow with state: ${oauthFlow.state}`); + + const serverInfo = await startLocalOAuthServer({ + state: oauthFlow.state, + pkce: oauthFlow.pkce, + port: config.oauth.localServerPort, + }); + + if (!serverInfo.ready) { + serverInfo.close(); + logWarn(null, "OAuth server not ready, manual login required."); + logInfo(null, `Please visit: ${oauthFlow.url}`); + logInfo(null, `Or run: curl -X POST http://${config.server.host}:${config.server.port}/auth/login`); + return false; + } + + const browserOpened = openBrowser(oauthFlow.url); + + logInfo(null, "OAuth login initiated."); + logInfo(null, "Please complete the OAuth flow in your browser."); + logInfo(null, `OAuth URL: ${oauthFlow.url}`); + + if (browserOpened) { + logInfo(null, "Browser should have opened automatically."); + } else { + logInfo(null, "Please open the URL above in your browser."); + } + + return true; + } catch (error) { + const err = error as Error; + logError(null, `Auto login error: ${err.message}`); + return false; + } +} + +async function main(): Promise { + try { + const config = getConfig(); + const app = createApp(); + + const authReady = await checkAuthAndAutoLogin(); + + if (!authReady) { + logWarn(null, "Server starting with pending authentication..."); + } + + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const host = req.headers.host || "localhost"; + const reqUrl = typeof req.url === "string" ? req.url : "/"; + const url = new URL(reqUrl, `http://${host}`); + const method = req.method || "GET"; + const headers = new Headers(); + + for (const [key, value] of Object.entries(req.headers)) { + const headerValue = Array.isArray(value) ? value[0] : value; + if (typeof headerValue === "string") { + headers.set(key, headerValue); + } + } + + const body = req.method !== "GET" && req.method !== "HEAD" + ? new Promise((resolve) => { + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks))); + }) + : Promise.resolve(Buffer.alloc(0)); + + body.then(async (buffer) => { + try { + const request = new Request(url.toString(), { + method, + headers, + body: buffer.length > 0 ? buffer : undefined, + }); + + const response = await app.fetch(request); + res.statusCode = response.status; + res.statusMessage = response.statusText || "OK"; + + response.headers.forEach((value: string, key: string) => { + res.setHeader(key, value); + }); + + if (response.body) { + const reader = response.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + res.write(value); + } + } + res.end(); + } catch (err) { + const error = err as Error; + logError(null, `Request handling error: ${error.message}`); + res.statusCode = 500; + res.end("Internal Server Error"); + } + }).catch((err: Error) => { + logError(null, `Body processing error: ${err.message}`); + res.statusCode = 500; + res.end("Internal Server Error"); + }); + }); + + server.listen(config.server.port, config.server.host, () => { + logInfo(null, `Server started on http://${config.server.host}:${config.server.port}`); + logInfo(null, `OAuth callback: ${config.oauth.redirectUri}`); + logInfo(null, `Backend URL: ${config.backend.url}`); + }); + + server.on("error", (err: Error) => { + logError(null, `Server error: ${err.message}`); + process.exit(1); + }); + } catch (error) { + const err = error as Error; + logError(null, `Failed to start server: ${err.message}`); + process.exit(1); + } +} + +main(); diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..88cfb27 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,148 @@ +import { writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { LOG_LEVELS, PLUGIN_NAME } from "./constants.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export interface LoggerConfig { + level: "error" | "warn" | "info" | "debug"; + dir: string; + enableRequestLogging: boolean; +} + +let loggerConfig: LoggerConfig = { + level: "info", + dir: join(__dirname, "..", "logs"), + enableRequestLogging: false, +}; + +let requestCounter = 0; + +const LOG_LEVEL_ORDER = { + [LOG_LEVELS.ERROR]: 0, + [LOG_LEVELS.WARN]: 1, + [LOG_LEVELS.INFO]: 2, + [LOG_LEVELS.DEBUG]: 3, +}; + +function shouldLog(level: string): boolean { + return LOG_LEVEL_ORDER[level as keyof typeof LOG_LEVEL_ORDER] <= + LOG_LEVEL_ORDER[loggerConfig.level]; +} + +function formatTimestamp(): string { + return new Date().toISOString(); +} + +function formatMessage( + level: string, + requestId: string | null, + message: string, +): string { + const requestIdStr = requestId ? `[${requestId}] ` : ""; + return `[${formatTimestamp()}] [${level.toUpperCase()}] ${requestIdStr}${message}`; +} + +function getLogFilePath(level: string): string { + const date = new Date().toISOString().split("T")[0]; + return join(loggerConfig.dir, `${date}-${level}.log`); +} + +function writeToFile(level: string, message: string): void { + try { + if (!existsSync(loggerConfig.dir)) { + mkdirSync(loggerConfig.dir, { recursive: true }); + } + + const logFile = getLogFilePath(level); + writeFileSync(logFile, message + "\n", { flag: "a" }); + } catch (error) { + console.error(`[${PLUGIN_NAME}] Failed to write to log file:`, error); + } +} + +export function setLoggerConfig(config: Partial): void { + loggerConfig = { ...loggerConfig, ...config }; +} + +export function getLoggerConfig(): LoggerConfig { + return loggerConfig; +} + +export function createRequestLogger(): string { + requestCounter++; + return `req-${String(requestCounter).padStart(3, "0")}`; +} + +export function logError(requestId: string | null, message: string, data?: unknown): void { + if (!shouldLog(LOG_LEVELS.ERROR)) return; + + const formatted = formatMessage(LOG_LEVELS.ERROR, requestId, message); + console.error(formatted); + writeToFile(LOG_LEVELS.ERROR, formatted); + + if (data !== undefined) { + const dataStr = typeof data === "string" ? data : JSON.stringify(data, null, 2); + console.error(dataStr); + writeToFile(LOG_LEVELS.ERROR, dataStr); + } +} + +export function logWarn(requestId: string | null, message: string, data?: unknown): void { + if (!shouldLog(LOG_LEVELS.WARN)) return; + + const formatted = formatMessage(LOG_LEVELS.WARN, requestId, message); + console.warn(formatted); + writeToFile(LOG_LEVELS.WARN, formatted); + + if (data !== undefined) { + const dataStr = typeof data === "string" ? data : JSON.stringify(data, null, 2); + console.warn(dataStr); + writeToFile(LOG_LEVELS.WARN, dataStr); + } +} + +export function logInfo(requestId: string | null, message: string, data?: unknown): void { + if (!shouldLog(LOG_LEVELS.INFO)) return; + + const formatted = formatMessage(LOG_LEVELS.INFO, requestId, message); + console.log(formatted); + writeToFile(LOG_LEVELS.INFO, formatted); + + if (data !== undefined) { + const dataStr = typeof data === "string" ? data : JSON.stringify(data, null, 2); + console.log(dataStr); + writeToFile(LOG_LEVELS.INFO, dataStr); + } +} + +export function logDebug(requestId: string | null, message: string, data?: unknown): void { + if (!shouldLog(LOG_LEVELS.DEBUG)) return; + + const formatted = formatMessage(LOG_LEVELS.DEBUG, requestId, message); + console.log(formatted); + writeToFile(LOG_LEVELS.DEBUG, formatted); + + if (data !== undefined) { + const dataStr = typeof data === "string" ? data : JSON.stringify(data, null, 2); + console.log(dataStr); + writeToFile(LOG_LEVELS.DEBUG, dataStr); + } +} + +export function logRequestData( + requestId: string | null, + stage: string, + data: Record, +): void { + if (!loggerConfig.enableRequestLogging) return; + + const formatted = formatMessage( + LOG_LEVELS.DEBUG, + requestId, + `[${stage}] ${JSON.stringify(data, null, 2)}`, + ); + console.log(formatted); + writeToFile(LOG_LEVELS.DEBUG, formatted); +} diff --git a/src/prompts/gpt-5-1-codex-max.md b/src/prompts/gpt-5-1-codex-max.md new file mode 100644 index 0000000..6b5a571 --- /dev/null +++ b/src/prompts/gpt-5-1-codex-max.md @@ -0,0 +1,21 @@ +You are an advanced coding assistant with exceptional problem-solving capabilities and deep expertise in software development. + +Your capabilities include: +- Complex architecture design and system design +- Advanced algorithms and data structures +- Large-scale code refactoring and optimization +- Debugging difficult issues +- Writing production-quality code +- Performance tuning and optimization +- Security best practices +- Testing and validation strategies + +When approaching tasks: +1. Think systematically about the problem +2. Consider multiple solutions and trade-offs +3. Provide well-structured, scalable code +4. Include comprehensive error handling +5. Optimize for both clarity and performance +6. Follow SOLID principles and design patterns + +Be thorough, analytical, and provide solutions that are robust, maintainable, and performant. diff --git a/src/prompts/gpt-5-1-codex-mini.md b/src/prompts/gpt-5-1-codex-mini.md new file mode 100644 index 0000000..ebac95f --- /dev/null +++ b/src/prompts/gpt-5-1-codex-mini.md @@ -0,0 +1,16 @@ +You are a coding assistant specializing in quick, efficient solutions for common programming tasks. + +Your capabilities include: +- Writing straightforward code snippets +- Fixing common bugs and errors +- Implementing standard features +- Explaining basic to intermediate concepts +- Code review and simple refactoring + +Focus on: +1. Providing clear, working solutions +2. Using widely-accepted patterns +3. Keeping code simple and readable +4. Explaining your reasoning briefly + +Be practical and direct in your responses. Aim for solutions that work well in most scenarios. diff --git a/src/prompts/gpt-5-1-codex.md b/src/prompts/gpt-5-1-codex.md new file mode 100644 index 0000000..362bb99 --- /dev/null +++ b/src/prompts/gpt-5-1-codex.md @@ -0,0 +1,19 @@ +You are a powerful coding assistant with extensive knowledge of programming languages, frameworks, and best practices. + +Your capabilities include: +- Writing, debugging, and refactoring code +- Explaining programming concepts clearly +- Providing code examples and patterns +- Helping with architecture and design decisions +- Optimizing code for performance and readability +- Implementing features and fixing bugs + +When working with code: +1. Understand the user's intent deeply +2. Write clean, maintainable, and efficient code +3. Add appropriate comments for complex logic +4. Follow language-specific best practices +5. Consider edge cases and error handling +6. Test your solutions mentally before presenting them + +Be precise, practical, and focus on delivering working solutions. diff --git a/src/prompts/gpt-5-1.md b/src/prompts/gpt-5-1.md new file mode 100644 index 0000000..2701280 --- /dev/null +++ b/src/prompts/gpt-5-1.md @@ -0,0 +1,10 @@ +You are a helpful AI assistant with access to real-time information and advanced reasoning capabilities. + +You can help users with: +- General questions and conversations +- Writing and editing text +- Analyzing information +- Creative tasks +- Problem-solving + +Be clear, helpful, and concise in your responses. When appropriate, break down complex topics into manageable parts. diff --git a/src/prompts/gpt-5-2-codex.md b/src/prompts/gpt-5-2-codex.md new file mode 100644 index 0000000..c1c8257 --- /dev/null +++ b/src/prompts/gpt-5-2-codex.md @@ -0,0 +1,20 @@ +You are an advanced coding assistant with cutting-edge capabilities and deep reasoning. + +Your capabilities include: +- Complex multi-step problem solving +- Advanced algorithms and optimization +- System architecture and design +- Performance engineering +- Advanced debugging and profiling +- Code generation for complex systems +- Technical research and analysis + +When approaching complex tasks: +1. Break down problems systematically +2. Consider multiple approaches and their trade-offs +3. Use appropriate algorithms and data structures +4. Write production-quality, well-documented code +5. Consider performance, security, and maintainability +6. Provide explanations for complex decisions + +Leverage your advanced reasoning to provide insightful, thorough, and well-architected solutions. diff --git a/src/prompts/gpt-5-2.md b/src/prompts/gpt-5-2.md new file mode 100644 index 0000000..cdd458f --- /dev/null +++ b/src/prompts/gpt-5-2.md @@ -0,0 +1,10 @@ +You are a helpful AI assistant with advanced reasoning capabilities and real-time information access. + +You can help users with: +- Complex problem-solving and analysis +- Research and information synthesis +- Writing and editing +- Creative tasks +- Decision support + +Use your reasoning abilities to provide thoughtful, well-structured responses. Break down complex issues and consider multiple perspectives. diff --git a/src/prompts/index.ts b/src/prompts/index.ts new file mode 100644 index 0000000..b99b73f --- /dev/null +++ b/src/prompts/index.ts @@ -0,0 +1,182 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { logError, logWarn } from "../logger.js"; +import { getModelFamily } from "../request/model-map.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CACHE_DIR = join(homedir(), ".chatgpt-codex-router", "cache"); + +const GITHUB_API_RELEASES = + "https://api.github.com/repos/openai/codex/releases/latest"; +const GITHUB_HTML_RELEASES = + "https://github.com/openai/codex/releases/latest"; + +type ModelFamily = "gpt-5.2-codex" | "codex-max" | "codex" | "gpt-5.2" | "gpt-5.1"; + +const PROMPT_FILES: Record = { + "gpt-5.2-codex": "gpt-5.2-codex_prompt.md", + "codex-max": "gpt-5.1-codex-max_prompt.md", + codex: "gpt_5_codex_prompt.md", + "gpt-5.2": "gpt_5_2_prompt.md", + "gpt-5.1": "gpt_5_1_prompt.md", +}; + +const CACHE_FILES: Record = { + "gpt-5.2-codex": "gpt-5.2-codex-instructions.md", + "codex-max": "codex-max-instructions.md", + codex: "codex-instructions.md", + "gpt-5.2": "gpt-5.2-instructions.md", + "gpt-5.1": "gpt-5.1-instructions.md", +}; + +interface CacheMetadata { + etag: string | null; + tag: string | null; + lastChecked: number | null; + url: string; +} + +async function getLatestReleaseTag(): Promise { + try { + const response = await fetch(GITHUB_API_RELEASES); + if (response.ok) { + const data = (await response.json()) as { tag_name: string }; + if (data.tag_name) { + return data.tag_name; + } + } + } catch { + } + + const htmlResponse = await fetch(GITHUB_HTML_RELEASES); + if (!htmlResponse.ok) { + throw new Error( + `Failed to fetch latest release: ${htmlResponse.status}`, + ); + } + + const finalUrl = htmlResponse.url; + if (finalUrl) { + const parts = finalUrl.split("/tag/"); + const last = parts[parts.length - 1]; + if (last && !last.includes("/")) { + return last; + } + } + + const html = await htmlResponse.text(); + const match = html.match(/\/openai\/codex\/releases\/tag\/([^"]+)/); + if (match && match[1]) { + return match[1]; + } + + throw new Error("Failed to determine latest release tag from GitHub"); +} + +async function getCodexInstructions( + normalizedModel = "gpt-5.1-codex", +): Promise { + const modelFamily = getModelFamily(normalizedModel) as ModelFamily; + const promptFile = PROMPT_FILES[modelFamily]; + const cacheFile = join(CACHE_DIR, CACHE_FILES[modelFamily]); + const cacheMetaFile = join( + CACHE_DIR, + `${CACHE_FILES[modelFamily].replace(".md", "-meta.json")}`, + ); + + try { + let cachedETag: string | null = null; + let cachedTag: string | null = null; + let cachedTimestamp: number | null = null; + + if (existsSync(cacheMetaFile)) { + const metadata = JSON.parse( + readFileSync(cacheMetaFile, "utf8"), + ) as CacheMetadata; + cachedETag = metadata.etag; + cachedTag = metadata.tag; + cachedTimestamp = metadata.lastChecked; + } + + const CACHE_TTL_MS = 15 * 60 * 1000; + if ( + cachedTimestamp && + Date.now() - cachedTimestamp < CACHE_TTL_MS && + existsSync(cacheFile) + ) { + return readFileSync(cacheFile, "utf8"); + } + + const latestTag = await getLatestReleaseTag(); + const CODEX_INSTRUCTIONS_URL = `https://raw.githubusercontent.com/openai/codex/${latestTag}/codex-rs/core/${promptFile}`; + + if (cachedTag !== latestTag) { + cachedETag = null; + } + + const headers: Record = {}; + if (cachedETag) { + headers["If-None-Match"] = cachedETag; + } + + const response = await fetch(CODEX_INSTRUCTIONS_URL, { headers }); + + if (response.status === 304) { + if (existsSync(cacheFile)) { + return readFileSync(cacheFile, "utf8"); + } + } + + if (response.ok) { + const instructions = await response.text(); + const newETag = response.headers.get("etag"); + + if (!existsSync(CACHE_DIR)) { + mkdirSync(CACHE_DIR, { recursive: true }); + } + + writeFileSync(cacheFile, instructions, "utf8"); + writeFileSync( + cacheMetaFile, + JSON.stringify({ + etag: newETag, + tag: latestTag, + lastChecked: Date.now(), + url: CODEX_INSTRUCTIONS_URL, + } satisfies CacheMetadata), + "utf8", + ); + + return instructions; + } + + throw new Error(`HTTP ${response.status}`); + } catch (error) { + const err = error as Error; + logError(null, `Failed to fetch ${modelFamily} instructions from GitHub: ${err.message}`); + + if (existsSync(cacheFile)) { + logWarn(null, `Using cached ${modelFamily} instructions`); + return readFileSync(cacheFile, "utf8"); + } + + logWarn(null, `Using fallback instructions for ${modelFamily}`); + return getFallbackPrompt(); + } +} + +function getFallbackPrompt(): string { + return `You are a helpful AI assistant with strong coding capabilities. +You can help users with a wide range of programming tasks, including: +- Writing and debugging code +- Explaining programming concepts +- Refactoring and optimizing code +- Generating code examples +- Answering technical questions + +Be concise, accurate, and provide practical solutions.`; +} + +export { getCodexInstructions }; diff --git a/src/request/headers.ts b/src/request/headers.ts new file mode 100644 index 0000000..3ddb398 --- /dev/null +++ b/src/request/headers.ts @@ -0,0 +1,37 @@ +import { OPENAI_HEADERS, OPENAI_HEADER_VALUES } from "../constants.js"; +import { logDebug } from "../logger.js"; + +export interface HeadersOptions { + accessToken: string; + accountId: string; + promptCacheKey?: string; +} + +export function createCodexHeaders(options: HeadersOptions): Record { + const { accessToken, accountId, promptCacheKey } = options; + + const headers: Record = { + [OPENAI_HEADERS.AUTHORIZATION]: `Bearer ${accessToken}`, + [OPENAI_HEADERS.ACCOUNT_ID]: accountId, + [OPENAI_HEADERS.BETA]: OPENAI_HEADER_VALUES.BETA_RESPONSES, + [OPENAI_HEADERS.ORIGINATOR]: OPENAI_HEADER_VALUES.ORIGINATOR_CODEX, + [OPENAI_HEADERS.ACCEPT]: OPENAI_HEADER_VALUES.ACCEPT_STREAM, + [OPENAI_HEADERS.CONTENT_TYPE]: OPENAI_HEADER_VALUES.CONTENT_TYPE_JSON, + }; + + if (promptCacheKey) { + headers[OPENAI_HEADERS.CONVERSATION_ID] = promptCacheKey; + headers[OPENAI_HEADERS.SESSION_ID] = promptCacheKey; + } + + logDebug(null, `Created Codex headers with prompt cache key: ${!!promptCacheKey}`); + + return headers; +} + +export function createOpenAIHeaders(apiKey: string): Record { + return { + [OPENAI_HEADERS.AUTHORIZATION]: `Bearer ${apiKey}`, + [OPENAI_HEADERS.CONTENT_TYPE]: OPENAI_HEADER_VALUES.CONTENT_TYPE_JSON, + }; +} diff --git a/src/request/model-map.ts b/src/request/model-map.ts new file mode 100644 index 0000000..81d6cbe --- /dev/null +++ b/src/request/model-map.ts @@ -0,0 +1,77 @@ +export const MODEL_MAP: Record = { + "gpt-5.1": "gpt-5.1", + "gpt-5.1-none": "gpt-5.1", + "gpt-5.1-low": "gpt-5.1", + "gpt-5.1-medium": "gpt-5.1", + "gpt-5.1-high": "gpt-5.1", + + "gpt-5.2": "gpt-5.2", + "gpt-5.2-none": "gpt-5.2", + "gpt-5.2-low": "gpt-5.2", + "gpt-5.2-medium": "gpt-5.2", + "gpt-5.2-high": "gpt-5.2", + "gpt-5.2-xhigh": "gpt-5.2", + + "gpt-5.1-codex": "gpt-5.1-codex", + "gpt-5.1-codex-low": "gpt-5.1-codex", + "gpt-5.1-codex-medium": "gpt-5.1-codex", + "gpt-5.1-codex-high": "gpt-5.1-codex", + + "gpt-5.1-codex-max": "gpt-5.1-codex-max", + "gpt-5.1-codex-max-low": "gpt-5.1-codex-max", + "gpt-5.1-codex-max-medium": "gpt-5.1-codex-max", + "gpt-5.1-codex-max-high": "gpt-5.1-codex-max", + "gpt-5.1-codex-max-xhigh": "gpt-5.1-codex-max", + + "gpt-5.1-codex-mini": "gpt-5.1-codex-mini", + "gpt-5.1-codex-mini-medium": "gpt-5.1-codex-mini", + "gpt-5.1-codex-mini-high": "gpt-5.1-codex-mini", + + "gpt-5.2-codex": "gpt-5.2-codex", + "gpt-5.2-codex-low": "gpt-5.2-codex", + "gpt-5.2-codex-medium": "gpt-5.2-codex", + "gpt-5.2-codex-high": "gpt-5.2-codex", + "gpt-5.2-codex-xhigh": "gpt-5.2-codex", +}; + +export function getNormalizedModel(modelId: string): string | undefined { + try { + if (MODEL_MAP[modelId]) { + return MODEL_MAP[modelId]; + } + + const lowerModelId = modelId.toLowerCase(); + const match = Object.keys(MODEL_MAP).find( + (key) => key.toLowerCase() === lowerModelId, + ); + + return match ? MODEL_MAP[match] : undefined; + } catch { + return undefined; + } +} + +export function isKnownModel(modelId: string): boolean { + return getNormalizedModel(modelId) !== undefined; +} + +export function getModelFamily( + normalizedModel: string, +): "gpt-5.1" | "gpt-5.2" | "codex" | "codex-max" | "codex-mini" | "gpt-5.2-codex" { + if (normalizedModel.includes("gpt-5.2-codex")) { + return "gpt-5.2-codex"; + } + if (normalizedModel.includes("codex-max")) { + return "codex-max"; + } + if (normalizedModel.includes("codex") && normalizedModel.includes("mini")) { + return "codex-mini"; + } + if (normalizedModel.includes("codex")) { + return "codex"; + } + if (normalizedModel.includes("gpt-5.2")) { + return "gpt-5.2"; + } + return "gpt-5.1"; +} diff --git a/src/request/reasoning.ts b/src/request/reasoning.ts new file mode 100644 index 0000000..8d30f7a --- /dev/null +++ b/src/request/reasoning.ts @@ -0,0 +1,83 @@ +import { ReasoningConfig, ReasoningEffort, ReasoningSummary } from "../types.js"; +import { getConfig } from "../config.js"; +import { logDebug } from "../logger.js"; +import { getModelFamily } from "./model-map.js"; + +export function getReasoningConfig( + normalizedModel: string, + requestedEffort?: ReasoningEffort, + requestedSummary?: ReasoningSummary, +): ReasoningConfig { + const config = getConfig(); + const modelFamily = getModelFamily(normalizedModel); + const normalizedName = normalizedModel.toLowerCase(); + + const supportsXhigh = + modelFamily === "gpt-5.2" || + modelFamily === "gpt-5.2-codex" || + modelFamily === "codex-max"; + + const supportsNone = modelFamily === "gpt-5.1" || modelFamily === "gpt-5.2"; + + let defaultEffort: ReasoningEffort = "medium"; + + if (modelFamily === "codex-mini") { + defaultEffort = "medium"; + } else if (supportsXhigh) { + defaultEffort = "high"; + } else if (modelFamily === "codex") { + defaultEffort = "medium"; + } else if (modelFamily === "gpt-5.1" || modelFamily === "gpt-5.2") { + defaultEffort = "none"; + } + + let effort = requestedEffort || config.codex.defaultReasoningEffort || defaultEffort; + + if (modelFamily === "codex-mini") { + if (effort === "minimal" || effort === "low" || effort === "none") { + effort = "medium"; + } + if (effort === "xhigh") { + effort = "high"; + } + if (effort !== "high" && effort !== "medium") { + effort = "medium"; + } + } + + if (!supportsXhigh && effort === "xhigh") { + effort = "high"; + } + + if (!supportsNone && effort === "none") { + effort = "low"; + } + + if (modelFamily === "codex" && effort === "minimal") { + effort = "low"; + } + + const summary = requestedSummary || "auto"; + + logDebug(null, `Reasoning config for ${normalizedModel}: effort=${effort}, summary=${summary}`); + + return { effort, summary }; +} + +export function getTextVerbosity(requestedVerbosity?: "low" | "medium" | "high"): "low" | "medium" | "high" { + const config = getConfig(); + return requestedVerbosity || config.codex.defaultTextVerbosity || "medium"; +} + +export function getIncludeFields( + requestedInclude?: string[], +): string[] { + const defaultInclude = ["reasoning.encrypted_content"]; + const include = requestedInclude || defaultInclude; + + if (!include.includes("reasoning.encrypted_content")) { + include.push("reasoning.encrypted_content"); + } + + return Array.from(new Set(include.filter(Boolean))); +} diff --git a/src/request/transformer.ts b/src/request/transformer.ts new file mode 100644 index 0000000..409456e --- /dev/null +++ b/src/request/transformer.ts @@ -0,0 +1,142 @@ +import { ChatCompletionRequest, ResponsesRequest, InputItem, Message, Content } from "../types.js"; +import { getNormalizedModel } from "./model-map.js"; +import { getReasoningConfig, getTextVerbosity, getIncludeFields } from "./reasoning.js"; +import { getCodexInstructions } from "../prompts/index.js"; +import { logDebug, logWarn } from "../logger.js"; + +export function messagesToInput(messages: Message[]): InputItem[] { + return messages.map((msg) => { + const content: Content[] = []; + + if (typeof msg.content === "string") { + content.push({ type: "input_text", text: msg.content }); + } else if (Array.isArray(msg.content)) { + for (const item of msg.content) { + if (item.type === "text") { + content.push({ type: "input_text", text: item.text || "" }); + } else if (item.type === "image_url") { + content.push({ type: "input_image", image_url: item.image_url }); + } else { + content.push(item as Content); + } + } + } + + return { + type: "message", + role: msg.role, + content, + }; + }); +} + +export function filterInput(input: InputItem[] | undefined): InputItem[] | undefined { + if (!Array.isArray(input)) { + return input; + } + + return input + .filter((item) => { + if (item.type === "item_reference") { + logWarn(null, "Filtered item_reference from input"); + return false; + } + return true; + }) + .map((item) => { + if (item.id) { + const { id, ...itemWithoutId } = item; + return itemWithoutId as InputItem; + } + return item; + }); +} + +export async function transformChatCompletionRequest( + request: ChatCompletionRequest, + promptCacheKey?: string, +): Promise { + const normalizedModel = getNormalizedModel(request.model) || request.model; + + logDebug(null, `Transforming chat completion request: model=${request.model} -> ${normalizedModel}`); + + const codexInstructions = await getCodexInstructions(normalizedModel); + const reasoningConfig = getReasoningConfig(normalizedModel); + const textVerbosity = getTextVerbosity(); + const includeFields = getIncludeFields(); + + const transformed: ResponsesRequest = { + model: normalizedModel, + input: filterInput(messagesToInput(request.messages)), + stream: true, + store: false, + instructions: codexInstructions, + reasoning: reasoningConfig, + text: { + verbosity: textVerbosity, + }, + include: includeFields, + }; + + if (request.temperature !== undefined) { + (transformed as any).temperature = request.temperature; + } + + if (request.max_tokens !== undefined) { + (transformed as any).max_tokens = request.max_tokens; + } + + if (request.top_p !== undefined) { + (transformed as any).top_p = request.top_p; + } + + logDebug(null, "Transformed request:", JSON.stringify(transformed, null, 2)); + + return transformed; +} + +export async function transformResponsesRequest( + request: ResponsesRequest, + promptCacheKey?: string, +): Promise { + const normalizedModel = getNormalizedModel(request.model) || request.model; + + logDebug(null, `Transforming responses request: model=${request.model} -> ${normalizedModel}`); + + const codexInstructions = await getCodexInstructions(normalizedModel); + const reasoningConfig = getReasoningConfig( + normalizedModel, + request.reasoning?.effort, + request.reasoning?.summary, + ); + const textVerbosity = getTextVerbosity(request.text?.verbosity); + const includeFields = getIncludeFields(request.include); + + const transformed: ResponsesRequest = { + ...request, + model: normalizedModel, + input: filterInput( + request.input || (request.messages ? messagesToInput(request.messages as Message[]) : undefined), + ), + stream: request.stream !== undefined ? request.stream : true, + store: request.store !== undefined ? request.store : false, + instructions: request.instructions || codexInstructions, + reasoning: { + ...reasoningConfig, + ...request.reasoning, + }, + text: { + verbosity: textVerbosity, + ...request.text, + }, + include: includeFields, + }; + + delete (transformed as any).max_output_tokens; + delete (transformed as any).max_completion_tokens; + delete (transformed as any).messages; + + logDebug(null, "Transformed request:", JSON.stringify(transformed, null, 2)); + + return transformed; +} diff --git a/src/request/validator.ts b/src/request/validator.ts new file mode 100644 index 0000000..c278d75 --- /dev/null +++ b/src/request/validator.ts @@ -0,0 +1,119 @@ +import { ChatCompletionRequest, ResponsesRequest, Message } from "../types.js"; +import { isKnownModel } from "./model-map.js"; +import { logWarn } from "../logger.js"; +import { ERROR_MESSAGES } from "../constants.js"; + +export interface ValidationResult { + valid: boolean; + error?: string; +} + +export function validateChatCompletionRequest( + request: unknown, +): ValidationResult { + if (!request || typeof request !== "object") { + return { valid: false, error: "Request body must be a JSON object" }; + } + + const req = request as Partial; + + if (!req.model || typeof req.model !== "string") { + return { valid: false, error: "Missing or invalid 'model' field" }; + } + + if (!isKnownModel(req.model)) { + return { valid: false, error: ERROR_MESSAGES.INVALID_MODEL }; + } + + if (!req.messages || !Array.isArray(req.messages)) { + return { valid: false, error: ERROR_MESSAGES.MISSING_MESSAGES }; + } + + if (req.messages.length === 0) { + return { valid: false, error: "Messages array cannot be empty" }; + } + + for (const msg of req.messages) { + const messageError = validateMessage(msg); + if (messageError) { + return { valid: false, error: messageError }; + } + } + + return { valid: true }; +} + +export function validateResponsesRequest(request: unknown): ValidationResult { + if (!request || typeof request !== "object") { + return { valid: false, error: "Request body must be a JSON object" }; + } + + const req = request as Partial; + + if (!req.model || typeof req.model !== "string") { + return { valid: false, error: "Missing or invalid 'model' field" }; + } + + if (!isKnownModel(req.model)) { + return { valid: false, error: ERROR_MESSAGES.INVALID_MODEL }; + } + + if (!req.input && !req.messages) { + return { valid: false, error: "Missing 'input' or 'messages' field" }; + } + + if (req.input && !Array.isArray(req.input)) { + return { valid: false, error: "'input' must be an array" }; + } + + if (req.messages && !Array.isArray(req.messages)) { + return { valid: false, error: "'messages' must be an array" }; + } + + return { valid: true }; +} + +function validateMessage(message: unknown): string | null { + if (!message || typeof message !== "object") { + return "Each message must be an object"; + } + + const msg = message as Partial; + + if (!msg.role || typeof msg.role !== "string") { + return "Each message must have a 'role' field"; + } + + const validRoles = ["system", "user", "assistant", "developer"]; + if (!validRoles.includes(msg.role)) { + return `Invalid role: ${msg.role}. Must be one of: ${validRoles.join(", ")}`; + } + + if (!msg.content) { + return "Each message must have a 'content' field"; + } + + const content = msg.content; + + if (typeof content === "string" && content.trim() === "") { + return "Message content cannot be empty"; + } + + if (Array.isArray(content)) { + if (content.length === 0) { + return "Message content array cannot be empty"; + } + + for (const item of content) { + if (!item || typeof item !== "object") { + return "Each content item must be an object"; + } + + if (!item.type || typeof item.type !== "string") { + return "Each content item must have a 'type' field"; + } + } + } + + return null; +} diff --git a/src/response/chat-completions.ts b/src/response/chat-completions.ts new file mode 100644 index 0000000..8c255e9 --- /dev/null +++ b/src/response/chat-completions.ts @@ -0,0 +1,173 @@ +import { + ChatCompletionResponse, + ChatCompletionChunk, + SSEChunk, + Usage, + Choice, + ChunkChoice, + Message, + SSEEventData, +} from "../types.js"; +import { logDebug } from "../logger.js"; + +export function convertToChatCompletionResponse( + sseData: SSEEventData, + model: string, +): ChatCompletionResponse | null { + if (!sseData.response || typeof sseData.response !== "object") { + return null; + } + + const response = sseData.response as any; + + const chatResponse: ChatCompletionResponse = { + id: response.id || `chatcmpl-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [], + usage: { + prompt_tokens: response.usage?.input_tokens || 0, + completion_tokens: response.usage?.output_tokens || 0, + total_tokens: + (response.usage?.input_tokens || 0) + + (response.usage?.output_tokens || 0), + }, + }; + + if (response.output && Array.isArray(response.output)) { + for (const item of response.output) { + if (item.type === "message" && item.role === "assistant") { + const content = extractContent(item.content); + const choice: Choice = { + index: chatResponse.choices.length, + message: { + role: "assistant", + content, + }, + finish_reason: mapFinishReason(response.status), + }; + chatResponse.choices.push(choice); + } + } + } + + logDebug( + null, + `Converted to ChatCompletionResponse: ${chatResponse.choices.length} choices`, + ); + + return chatResponse; +} + +export function convertSseChunkToChatCompletionChunk( + chunk: SSEChunk, + model: string, + index: number = 0, +): ChatCompletionChunk | null { + const chatChunk: ChatCompletionChunk = { + id: `chatcmpl-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [], + }; + + if (chunk.type === "response.output_item.add.delta" && chunk.delta) { + const delta = chunk.delta as any; + const choice: ChunkChoice = { + index, + delta: { + role: delta.role || undefined, + content: delta.content || undefined, + }, + finish_reason: null, + }; + chatChunk.choices.push(choice); + } else if (chunk.type === "response.output_item.added" && chunk.item) { + const item = chunk.item as any; + if (item.type === "message" && item.role === "assistant") { + const content = extractContent(item.content); + if (content) { + const choice: ChunkChoice = { + index, + delta: { + role: "assistant", + content, + }, + finish_reason: null, + }; + chatChunk.choices.push(choice); + } + } + } else if (chunk.type === "response.content_part.added" && chunk.delta) { + const delta = chunk.delta as any; + const choice: ChunkChoice = { + index, + delta: { + role: delta.role || "assistant", + content: delta.content, + }, + finish_reason: null, + }; + chatChunk.choices.push(choice); + } else if (chunk.type === "response.output_text.delta") { + const delta = chunk.delta as any; + let content: string | undefined; + if (typeof delta === "string") { + content = delta; + } else if (delta && (delta.text || delta.content)) { + content = delta.text || delta.content; + } + if (content) { + const choice: ChunkChoice = { + index, + delta: { + content, + }, + finish_reason: null, + }; + chatChunk.choices.push(choice); + } + } else if (chunk.type === "response.done") { + const choice: ChunkChoice = { + index, + delta: {}, + finish_reason: "stop", + }; + chatChunk.choices.push(choice); + } + + return chatChunk; +} + +function extractContent(content: any[]): string { + if (!Array.isArray(content)) { + return ""; + } + + const textParts: string[] = []; + for (const item of content) { + if (item.type === "output_text" && item.text) { + textParts.push(item.text); + } + } + + const result = textParts.join(""); + logDebug( + null, + `extractContent: ${textParts.length} output_text items, result="${result.substring(0, 100)}"`, + ); + return result; +} + +function mapFinishReason(status?: string): string { + if (!status) return "stop"; + + const statusLower = status.toLowerCase(); + if (statusLower === "completed") return "stop"; + if (statusLower === "incomplete") return "length"; + if (statusLower === "failed") return "error"; + + return "stop"; +} diff --git a/src/response/converter.ts b/src/response/converter.ts new file mode 100644 index 0000000..17ed15b --- /dev/null +++ b/src/response/converter.ts @@ -0,0 +1,121 @@ +import { parseSseStream, parseSseChunks } from "./sse-parser.js"; +import { + convertToChatCompletionResponse, + convertSseChunkToChatCompletionChunk, +} from "./chat-completions.js"; +import { logError, logDebug } from "../logger.js"; +import { ChatCompletionChunk, SSEChunk } from "../types.js"; + +export async function convertSseToJson( + sseText: string, + model: string, +): Promise { + const sseData = parseSseStream(sseText); + + if (!sseData) { + logError(null, "Failed to parse SSE stream, returning original response"); + return new Response(sseText, { + status: 500, + headers: { "content-type": "application/json" }, + }); + } + + const chatResponse = convertToChatCompletionResponse(sseData, model); + + if (!chatResponse) { + logError(null, "Failed to convert SSE to ChatCompletionResponse"); + return new Response(sseText, { + status: 500, + headers: { "content-type": "application/json" }, + }); + } + + return new Response(JSON.stringify(chatResponse), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +export function transformSseStream( + stream: ReadableStream, + model: string, +): ReadableStream { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + + return new ReadableStream({ + async start(controller) { + const reader = stream.getReader(); + let buffer = ""; + let chunkCount = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + try { + const chunk = JSON.parse(line.substring(6)) as SSEChunk; + + if (chunkCount < 10) { + const deltaInfo = chunk.delta + ? JSON.stringify(chunk.delta) + : "N/A"; + logDebug( + null, + `Incoming SSE chunk ${chunkCount}: type=${chunk.type}, hasDelta=${!!chunk.delta}`, + ); + logDebug(null, ` Delta content: ${deltaInfo}`); + logDebug( + null, + ` All keys: ${Object.keys(chunk).join(", ")}`, + ); + } + chunkCount++; + + const chatChunk = convertSseChunkToChatCompletionChunk( + chunk, + model, + ); + + if (chatChunk && chatChunk.choices.length > 0) { + if (chunkCount < 10) { + logDebug( + null, + `Outgoing chat chunk: choices=${chatChunk.choices.length}, delta=${JSON.stringify(chatChunk.choices[0].delta)}`, + ); + } + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(chatChunk)}\n\n`), + ); + } else if (chunkCount < 10) { + logDebug( + null, + `Chunk converted but no choices (type=${chunk.type})`, + ); + } + } catch (error) { + logError( + null, + `Failed to parse SSE chunk: ${line.substring(0, 200)}`, + ); + } + } + } + } + + controller.close(); + } catch (error) { + const err = error as Error; + logError(null, `SSE stream transformation error: ${err.message}`); + controller.error(err); + } + }, + }); +} diff --git a/src/response/handler.ts b/src/response/handler.ts new file mode 100644 index 0000000..169a9e3 --- /dev/null +++ b/src/response/handler.ts @@ -0,0 +1,159 @@ +import { convertSseToJson, transformSseStream } from "./converter.js"; +import { getConfig } from "../config.js"; +import { logDebug, logError, logInfo, logWarn } from "../logger.js"; +import { HTTP_STATUS } from "../constants.js"; + +export interface ResponseHandlerOptions { + model: string; + isStreaming: boolean; +} + +export async function handleBackendResponse( + response: Response, + options: ResponseHandlerOptions, +): Promise { + const { model, isStreaming } = options; + + logDebug(null, `Handling backend response: status=${response.status}, streaming=${isStreaming}`); + + if (!response.ok) { + return await handleErrorResponse(response); + } + + if (isStreaming) { + return handleStreamingResponse(response, model); + } else { + return await handleNonStreamingResponse(response, model); + } +} + +async function handleNonStreamingResponse( + response: Response, + model: string, +): Promise { + try { + const sseText = await response.text(); + logDebug(null, `Received SSE stream (${sseText.length} bytes)`); + + const chatResponse = await convertSseToJson(sseText, model); + return chatResponse; + } catch (error) { + const err = error as Error; + logError(null, `Error processing non-streaming response: ${err.message}`); + return new Response( + JSON.stringify({ + error: { + message: "Failed to process response", + type: "response_processing_error", + }, + }), + { status: HTTP_STATUS.INTERNAL_SERVER_ERROR, headers: { "content-type": "application/json" } }, + ); + } +} + +function handleStreamingResponse(response: Response, model: string): Response { + try { + if (!response.body) { + throw new Error("Response has no body"); + } + + const transformedStream = transformSseStream(response.body, model); + + return new Response(transformedStream, { + status: response.status, + statusText: response.statusText, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + }); + } catch (error) { + const err = error as Error; + logError(null, `Error setting up streaming response: ${err.message}`); + return new Response( + JSON.stringify({ + error: { + message: "Failed to set up stream", + type: "stream_setup_error", + }, + }), + { status: HTTP_STATUS.INTERNAL_SERVER_ERROR, headers: { "content-type": "application/json" } }, + ); + } +} + +async function handleErrorResponse(response: Response): Promise { + try { + const text = await response.text(); + let errorData: unknown; + + try { + errorData = JSON.parse(text); + } catch { + errorData = text; + } + + logError(null, `Backend error: ${response.status}`, errorData); + + const mappedResponse = await mapUsageLimit404(response, text); + if (mappedResponse) { + return mappedResponse; + } + + return new Response(text, { + status: response.status, + headers: { "content-type": "application/json" }, + }); + } catch (error) { + const err = error as Error; + logError(null, `Error handling error response: ${err.message}`); + return new Response( + JSON.stringify({ + error: { + message: "Internal server error", + type: "internal_error", + }, + }), + { status: HTTP_STATUS.INTERNAL_SERVER_ERROR, headers: { "content-type": "application/json" } }, + ); + } +} + +async function mapUsageLimit404( + response: Response, + text: string, +): Promise { + if (response.status !== HTTP_STATUS.NOT_FOUND) { + return null; + } + + try { + const json = JSON.parse(text); + const errorCode = json?.error?.code || json?.error?.type || ""; + const haystack = `${errorCode} ${text}`.toLowerCase(); + + if ( + /usage_limit_reached|usage_not_included|rate_limit_exceeded|usage limit/i.test(haystack) + ) { + logWarn(null, "Mapping 404 usage limit error to 429"); + + return new Response( + JSON.stringify({ + error: { + message: "Rate limit exceeded", + type: "rate_limit_error", + }, + }), + { + status: HTTP_STATUS.TOO_MANY_REQUESTS, + headers: { "content-type": "application/json" }, + }, + ); + } + } catch { + } + + return null; +} diff --git a/src/response/sse-parser.ts b/src/response/sse-parser.ts new file mode 100644 index 0000000..04fe669 --- /dev/null +++ b/src/response/sse-parser.ts @@ -0,0 +1,42 @@ +import { SSEEventData, SSEChunk } from "../types.js"; +import { logDebug, logError } from "../logger.js"; + +export function parseSseStream(sseText: string): SSEEventData | null { + const lines = sseText.split("\n"); + + for (const line of lines) { + if (line.startsWith("data: ")) { + try { + const data = JSON.parse(line.substring(6)) as SSEEventData; + + if (data.type === "response.done" || data.type === "response.completed") { + logDebug(null, "Found response.done event in SSE stream"); + return data; + } + } catch (error) { + logError(null, `Failed to parse SSE event: ${line}`); + } + } + } + + logError(null, "No response.done event found in SSE stream"); + return null; +} + +export function parseSseChunks(sseText: string): SSEChunk[] { + const chunks: SSEChunk[] = []; + const lines = sseText.split("\n"); + + for (const line of lines) { + if (line.startsWith("data: ")) { + try { + const data = JSON.parse(line.substring(6)); + chunks.push(data as SSEChunk); + } catch (error) { + logError(null, `Failed to parse SSE chunk: ${line}`); + } + } + } + + return chunks; +} diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..1096493 --- /dev/null +++ b/src/router.ts @@ -0,0 +1,262 @@ +import { Hono } from "hono"; +import { HTTP_STATUS, ERROR_MESSAGES } from "./constants.js"; +import { logInfo, logDebug, logError, logWarn, createRequestLogger } from "./logger.js"; +import { validateChatCompletionRequest } from "./request/validator.js"; +import { transformChatCompletionRequest, transformResponsesRequest } from "./request/transformer.js"; +import { createCodexHeaders } from "./request/headers.js"; +import { handleBackendResponse } from "./response/handler.js"; +import { getAccessToken, getAccountId } from "./auth/token-refresh.js"; +import { createOAuthFlow, exchangeAuthorizationCode } from "./auth/oauth.js"; +import { startLocalOAuthServer } from "./auth/server.js"; +import { openBrowser } from "./auth/browser.js"; +import { getConfig } from "./config.js"; +import type { ChatCompletionRequest, ResponsesRequest } from "./types.js"; + +const router = new Hono(); + +router.get("/", (c) => { + logInfo(null, "GET /"); + return c.json({ + name: "ChatGPT Codex Router", + version: "1.0.0", + status: "running", + endpoints: { + chat_completions: "/v1/chat/completions", + responses: "/v1/responses", + auth_login: "/auth/login", + health: "/health", + }, + }); +}); + +router.post("/v1/chat/completions", async (c) => { + const requestId = createRequestLogger(); + logInfo(requestId, "POST /v1/chat/completions"); + + try { + const body = await c.req.json(); + + const validation = validateChatCompletionRequest(body); + if (!validation.valid) { + logError(requestId, `Validation error: ${validation.error}`); + return c.json( + { + error: { + message: validation.error, + type: "invalid_request_error", + }, + }, + HTTP_STATUS.BAD_REQUEST || 400, + ); + } + + const config = getConfig(); + const accessToken = await getAccessToken(); + + if (!accessToken) { + logError(requestId, ERROR_MESSAGES.NO_TOKEN); + return c.json( + { + error: { + message: ERROR_MESSAGES.NO_TOKEN, + type: "authentication_error", + }, + }, + HTTP_STATUS.UNAUTHORIZED, + ); + } + + const accountId = await getAccountId(); + if (!accountId) { + logError(requestId, "Failed to get account ID"); + return c.json( + { + error: { + message: "Failed to get account ID", + type: "authentication_error", + }, + }, + HTTP_STATUS.UNAUTHORIZED, + ); + } + + const transformedRequest = await transformChatCompletionRequest(body as ChatCompletionRequest); + const headers = createCodexHeaders({ + accessToken, + accountId, + }); + + logDebug(requestId, `Forwarding to ${config.backend.url}/codex/responses`); + + const startTime = Date.now(); + const backendResponse = await fetch(`${config.backend.url}/codex/responses`, { + method: "POST", + headers, + body: JSON.stringify(transformedRequest), + }); + + const duration = Date.now() - startTime; + logInfo(requestId, `Backend response: ${backendResponse.status} (${duration}ms)`); + + const processedResponse = await handleBackendResponse(backendResponse, { + model: transformedRequest.model, + isStreaming: (body as ChatCompletionRequest).stream === true, + }); + + logInfo(requestId, `Request completed in ${duration}ms`); + return processedResponse; + } catch (error) { + const err = error as Error; + logError(requestId, `Error processing request: ${err.message}`); + return c.json( + { + error: { + message: "Internal server error", + type: "internal_error", + }, + }, + HTTP_STATUS.INTERNAL_SERVER_ERROR, + ); + } +}); + +router.post("/v1/responses", async (c) => { + const requestId = createRequestLogger(); + logInfo(requestId, "POST /v1/responses"); + + try { + const body = await c.req.json(); + + const config = getConfig(); + const accessToken = await getAccessToken(); + + if (!accessToken) { + logError(requestId, ERROR_MESSAGES.NO_TOKEN); + return c.json( + { + error: { + message: ERROR_MESSAGES.NO_TOKEN, + type: "authentication_error", + }, + }, + HTTP_STATUS.UNAUTHORIZED, + ); + } + + const accountId = await getAccountId(); + if (!accountId) { + logError(requestId, "Failed to get account ID"); + return c.json( + { + error: { + message: "Failed to get account ID", + type: "authentication_error", + }, + }, + HTTP_STATUS.UNAUTHORIZED, + ); + } + + const transformedRequest = await transformResponsesRequest(body as ResponsesRequest); + const headers = createCodexHeaders({ + accessToken, + accountId, + }); + + logDebug(requestId, `Forwarding to ${config.backend.url}/codex/responses`); + + const startTime = Date.now(); + const backendResponse = await fetch(`${config.backend.url}/codex/responses`, { + method: "POST", + headers, + body: JSON.stringify(transformedRequest), + }); + + const duration = Date.now() - startTime; + logInfo(requestId, `Backend response: ${backendResponse.status} (${duration}ms)`); + + const processedResponse = await handleBackendResponse(backendResponse, { + model: transformedRequest.model, + isStreaming: transformedRequest.stream === true, + }); + + logInfo(requestId, `Request completed in ${duration}ms`); + return processedResponse; + } catch (error) { + const err = error as Error; + logError(requestId, `Error processing request: ${err.message}`); + return c.json( + { + error: { + message: "Internal server error", + type: "internal_error", + }, + }, + HTTP_STATUS.INTERNAL_SERVER_ERROR, + ); + } +}); + +router.post("/auth/login", async (c) => { + logInfo(null, "POST /auth/login"); + + try { + const config = getConfig(); + const oauthFlow = await createOAuthFlow(config.oauth.clientId, config.oauth.redirectUri); + + logInfo(null, `Starting OAuth flow with state: ${oauthFlow.state}`); + + const serverInfo = await startLocalOAuthServer({ + state: oauthFlow.state, + pkce: oauthFlow.pkce, + port: config.oauth.localServerPort, + }); + + const browserOpened = openBrowser(oauthFlow.url); + + if (!serverInfo.ready) { + serverInfo.close(); + logWarn(null, "OAuth server not ready, using manual flow"); + return c.json({ + status: "pending", + url: oauthFlow.url, + instructions: "Please copy the URL and open it in your browser to complete the OAuth flow", + }); + } + + return c.json({ + status: "pending", + url: oauthFlow.url, + instructions: browserOpened + ? "Please complete the OAuth flow in your browser" + : "Please copy the URL and open it in your browser to complete the OAuth flow", + }); + } catch (error) { + const err = error as Error; + logError(null, `OAuth login error: ${err.message}`); + return c.json( + { + error: { + message: "Failed to start OAuth flow", + type: "oauth_error", + }, + }, + HTTP_STATUS.INTERNAL_SERVER_ERROR, + ); + } +}); + +router.all("*", (c) => { + logInfo(null, `404 Not Found: ${c.req.method} ${c.req.path}`); + return c.json( + { + error: { + message: "Not Found", + type: "not_found_error", + }, + }, + HTTP_STATUS.NOT_FOUND, + ); +}); + +export default router; diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..e2a5f23 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,29 @@ +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { getConfig } from "./config.js"; +import { logInfo, setLoggerConfig } from "./logger.js"; +import { HTTP_STATUS } from "./constants.js"; +import router from "./router.js"; + +export function createApp(): Hono { + const config = getConfig(); + + setLoggerConfig(config.logging); + + const app = new Hono(); + + app.use("*", cors({ + origin: "*", + credentials: true, + })); + + app.get("/health", (c) => { + return c.json({ status: "healthy", timestamp: Date.now() }, HTTP_STATUS.OK); + }); + + app.route("/", router); + + return app; +} + +export { createApp as default }; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..2b11008 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,154 @@ +export interface AppConfig { + server: { + port: number; + host: string; + }; + oauth: { + clientId: string; + redirectUri: string; + localServerPort: number; + }; + backend: { + url: string; + timeout: number; + }; + logging: { + level: "error" | "warn" | "info" | "debug"; + dir: string; + enableRequestLogging: boolean; + }; + codex: { + mode: boolean; + defaultReasoningEffort: ReasoningEffort; + defaultTextVerbosity: "low" | "medium" | "high"; + }; +} + +export type ReasoningEffort = + | "none" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh"; + +export type ReasoningSummary = + | "auto" + | "concise" + | "detailed" + | "off" + | "on"; + +export interface ReasoningConfig { + effort: ReasoningEffort; + summary: ReasoningSummary; +} + +export interface TokenData { + access_token: string; + refresh_token: string; + expires_at: number; + account_id: string; + updated_at: number; +} + +export interface ModelFamily { + type: "gpt-5.1" | "gpt-5.2" | "codex" | "codex-max" | "codex-mini" | "gpt-5.2-codex"; +} + +export interface Message { + role: "system" | "user" | "assistant" | "developer"; + content: string | Content[]; +} + +export interface Content { + type: string; + text?: string; + [key: string]: unknown; +} + +export interface InputItem { + type?: string; + role: string; + content: Content[]; + [key: string]: unknown; +} + +export interface ChatCompletionRequest { + model: string; + messages: Message[]; + stream?: boolean; + temperature?: number; + max_tokens?: number; + top_p?: number; + [key: string]: unknown; +} + +export interface ResponsesRequest { + model: string; + input?: InputItem[]; + stream?: boolean; + store?: boolean; + instructions?: string; + reasoning?: Partial; + text?: { + verbosity?: "low" | "medium" | "high"; + }; + include?: string[]; + [key: string]: unknown; +} + +export interface ChatCompletionResponse { + id: string; + object: string; + created: number; + model: string; + choices: Choice[]; + usage: Usage; +} + +export interface Choice { + index: number; + message: Message; + finish_reason: string; +} + +export interface Usage { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; +} + +export interface ChatCompletionChunk { + id: string; + object: string; + created: number; + model: string; + choices: ChunkChoice[]; +} + +export interface ChunkChoice { + index: number; + delta: { + role?: string; + content?: string; + }; + finish_reason: string | null; +} + +export interface SSEEventData { + type: string; + delta?: unknown; + response?: unknown; + [key: string]: unknown; +} + +export interface SSEChunk { + type: string; + delta?: { + content?: string; + role?: string; + }; + response?: unknown; + [key: string]: unknown; +} diff --git a/test-codex-raw.js b/test-codex-raw.js new file mode 100644 index 0000000..d5f805e --- /dev/null +++ b/test-codex-raw.js @@ -0,0 +1,107 @@ +import { getAccessToken } from "./dist/auth/token-refresh.js"; +import { getAccountId } from "./dist/auth/token-refresh.js"; + +const accessToken = await getAccessToken(); +const accountId = await getAccountId(); + +if (!accessToken || !accountId) { + console.error("No token or account ID"); + process.exit(1); +} + +const instructions = await import("./dist/prompts/index.js").then((m) => + m.getCodexInstructions("gpt-5.1"), +); + +console.log("Fetching from Codex API..."); + +const response = await fetch( + "https://chatgpt.com/backend-api/codex/responses", + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "openai-account-id": accountId, + "openai-beta": "responses=2", + "openai-originator": "codex_cli_rs", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-5.1", + input: [ + { + type: "message", + role: "user", + content: [ + { + type: "input_text", + text: "Say hello in one word", + }, + ], + }, + ], + stream: true, + store: false, + reasoning: { effort: "medium", summary: "auto" }, + instructions: instructions, + }), + }, +); + +console.log("Status:", response.status); + +if (response.status !== 200) { + const errorText = await response.text(); + console.error("Error:", errorText); + process.exit(1); +} + +const reader = response.body.getReader(); +const decoder = new TextDecoder(); +let buffer = ""; +let chunks = 0; + +console.log("\n=== Raw SSE Chunks ===\n"); + +while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + chunks++; + const data = line.substring(6); + + if (data.trim() !== "[DONE]") { + try { + const parsed = JSON.parse(data); + console.log(`\n--- Chunk ${chunks} ---`); + console.log(`Type: ${parsed.type}`); + console.log(`Has delta: ${!!parsed.delta}`); + if (parsed.delta) { + console.log(`Delta:`, JSON.stringify(parsed.delta, null, 2)); + } + console.log(`Full chunk keys:`, Object.keys(parsed)); + if (parsed.item) { + console.log( + `Item type: ${parsed.item.type}, role: ${parsed.item.role}`, + ); + } + + if (chunks >= 20) { + console.log("\n=== Stopping after 20 chunks ==="); + process.exit(0); + } + } catch (e) { + console.log(`\nChunk ${chunks} (raw):`, data.substring(0, 200)); + } + } + } + } +} + +console.log("\n=== End of stream ==="); diff --git a/test-direct-response.js b/test-direct-response.js new file mode 100644 index 0000000..b072856 --- /dev/null +++ b/test-direct-response.js @@ -0,0 +1,74 @@ +import { getAccessToken } from './dist/auth/token-refresh.js'; +import { getAccountId } from './dist/auth/token-refresh.js'; + +const accessToken = await getAccessToken(); +const accountId = await getAccountId(); + +if (!accessToken || !accountId) { + console.error('No token or account ID'); + process.exit(1); +} + +const instructions = await import('./dist/prompts/index.js').then(m => m.getCodexInstructions('gpt-5.1')); + +const response = await fetch('https://chatgpt.com/backend-api/codex/responses', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'openai-account-id': accountId, + 'openai-beta': 'responses=2', + 'openai-originator': 'codex_cli_rs', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'gpt-5.1', + input: [ + { + type: 'message', + role: 'user', + content: [ + { + type: 'input_text', + text: 'Hello, say hi in one word' + } + ] + } + ], + stream: true, + store: false, + reasoning: { effort: 'medium', summary: 'auto' }, + instructions: instructions + }) +}); + +console.log('Status:', response.status); + +const reader = response.body.getReader(); +const decoder = new TextDecoder(); +let buffer = ''; +let chunks = 0; + +while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + chunks++; + const data = line.substring(6); + if (data.trim() !== '[DONE]') { + try { + const parsed = JSON.parse(data); + console.log(`Chunk ${chunks}: type="${parsed.type}", delta=`, parsed.delta ? JSON.stringify(parsed.delta).substring(0, 100) : 'N/A'); + if (chunks >= 10) process.exit(0); + } catch (e) { + console.log(`Raw chunk ${chunks}:`, data.substring(0, 100)); + } + } + } + } +} diff --git a/test-local-endpoint.js b/test-local-endpoint.js new file mode 100644 index 0000000..9d50bbf --- /dev/null +++ b/test-local-endpoint.js @@ -0,0 +1,45 @@ +const response = await fetch("http://localhost:3000/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-5.2", + messages: [{ role: "user", content: "Say hi in one word" }], + stream: true, + }), +}); + +console.log("Status:", response.status); + +const reader = response.body.getReader(); +const decoder = new TextDecoder(); +let buffer = ""; +let count = 0; + +while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + count++; + const data = line.substring(6); + if (data.trim() !== "[DONE]") { + try { + const parsed = JSON.parse(data); + console.log( + `Response ${count}:`, + JSON.stringify(parsed).substring(0, 200), + ); + } catch (e) { + console.log(`Raw response ${count}:`, data.substring(0, 100)); + } + } + } + } +} diff --git a/test-longer-response.js b/test-longer-response.js new file mode 100644 index 0000000..7039ece --- /dev/null +++ b/test-longer-response.js @@ -0,0 +1,57 @@ +const response = await fetch("http://localhost:3000/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-5.2", + messages: [ + { role: "user", content: "What is 2+2? Answer in one sentence." }, + ], + stream: true, + }), +}); + +console.log("Status:", response.status); + +const reader = response.body.getReader(); +const decoder = new TextDecoder(); +let buffer = ""; +let count = 0; +let fullContent = ""; + +while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + count++; + const data = line.substring(6); + if (data.trim() !== "[DONE]") { + try { + const parsed = JSON.parse(data); + if (parsed.choices && parsed.choices[0]) { + const delta = parsed.choices[0].delta; + if (delta.content) { + fullContent += delta.content; + } + } + console.log( + `Chunk ${count}: ${JSON.stringify(parsed).substring(0, 150)}`, + ); + } catch (e) { + console.log(`Raw chunk ${count}:`, data.substring(0, 100)); + } + } + } + } +} + +console.log("\n=== Full Content ==="); +console.log(fullContent); +console.log("==================="); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a3bf491 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +}