# llm-proxy

[English](./README.md) | [简体中文](./README.zh.md)

本地 LLM 代理服务，单端口同时提供管理 UI 和 AI API，支持多协议路由、协议互转、流式 SSE 转换、token 统计、协议抓包调试。

## 功能特性

- 🔀 **多协议支持**：单端口提供 Anthropic、OpenAI、OpenAI Responses 三种协议
- 🔄 **协议互转**：三个协议间双向转换（流式 + 非流式）
- 📸 **外挂识图**：为不支持图片的模型自动接入识图模型，把图片转成文字描述再转发；自带持久化 LRU 缓存
- 🖥️ **macOS 桌面应用**：原生菜单栏 App，内嵌代理服务，拖拽安装、零依赖
- 📊 **管理界面**：Alpine.js 单页应用，包含仪表盘、Provider 管理、适配器配置、识图设置、抓包调试
- 🎯 **虚拟适配器**：自定义端点 + 模型重映射（`/{adapter-name}/v1/...`）
- 📡 **SSE 流式**：4 个双向流转换器，每行带时间戳
- 🔍 **协议抓包**：环形缓冲记录原始请求/响应，支持左右对比 + 差异分析
- 🔥 **热加载**：配置原子替换，进行中请求不受影响
- 📈 **Token 统计**：按 Provider 统计 token 使用量

## 截图

<p align="center">
  <img src="docs/images/macos-cn.png" alt="macOS 菜单栏" width="380"/>&nbsp;&nbsp;
  <img src="docs/images/macos-cn2.png" alt="macOS 菜单栏" width="380"/>
</p>

<p align="center"><em>macOS 菜单栏 — 服务控制、适配器切换、语言设置 &nbsp;|&nbsp; 适配器列表和模型映射</em></p>

<br>

<img src="docs/images/admin-dashboard-cn.png" alt="Admin 仪表盘" width="720"/>

<p align="center"><em>管理仪表盘 — Provider 状态、Token 用量、代理密钥管理</em></p>

<br>

<img src="docs/images/admin-providers-cn.png" alt="Admin Providers" width="720"/>

<p align="center"><em>Provider 管理 — 添加/编辑/删除 AI 服务商，拉取模型列表，声明输入模态</em></p>

<br>

<img src="docs/images/admin-adapters-cn.png" alt="Admin Adapters" width="720"/>

<p align="center"><em>适配器配置 — 虚拟端点 + 模型重映射</em></p>

## 安装

**macOS（推荐）：**
从 [Releases](https://github.com/maplezzk/llm-proxy/releases) 下载 `LLMProxy.dmg`，拖入 `/Applications`。如果 macOS 阻止运行，执行：
```bash
xattr -cr /Applications/LLMProxy.app
```
再次打开即可。内含完整代理服务和管理界面。

**macOS（Homebrew）：**
```bash
brew tap maplezzk/tap && brew install --cask llm-proxy
```

**仅 CLI：**
```bash
npm install -g @maplezzk/llm-proxy
```

## 快速开始

```bash
# 启动
llm-proxy start

# 打开管理界面 → http://127.0.0.1:9000/admin/
```

首次启动会自动创建配置目录。打开管理界面在浏览器里完成所有配置，无需手写 YAML。

管理界面支持：
- **Provider 管理**：增删改 AI 服务商、拉取远程模型列表、声明输入模态（文本/图片）
- **适配器配置**：创建带模型重映射和协议转换的虚拟端点
- **识图设置**：为不支持图片的模型启用外挂识图、查看缓存命中率
- **管理密钥**：按当前服务地址保存独立的管理 API 密钥，用于访问受保护的管理接口
- **代理密钥**：设置 API 认证密钥
- **连通性测试**：直接发送测试请求验证配置
- **协议抓包**：实时查看请求/响应原文

## 配置

`~/.llm-proxy/config.yaml`:

```yaml
log_level: debug          # debug | info | warn | error
port: 9000                # 可选：默认 9000
max_body_size: 10485760   # 可选：请求体大小上限（字节，默认 10MB）
admin_key: ${ADMIN_KEY}   # 可选：保护管理 API；/admin 页面外壳保持公开
proxy_key: sk-xxx         # 可选：设置后 /v1/* 需认证

providers:
  - name: deepseek
    type: openai          # anthropic | openai | openai-responses
    api_key: ${DEEPSEEK_API_KEY}
    api_base: https://api.deepseek.com
    models:
      - id: deepseek-chat

  - name: anthropic
    type: anthropic
    api_key: ${ANTHROPIC_API_KEY}
    models:
      # 多模态模型 — 显式声明支持图片
      - id: claude-sonnet-4
        input: [text, image]
        thinking:
          budget_tokens: 10000

      # 非多模态模型 — 图片请求会自动走外挂识图
      - id: deepseek-reasoner
        # 不写 input → 默认 [text]；图片请求触发外挂识图

      # MiniMax 思考模式透传（非标准 thinking.type）
      - id: MiniMax-M2
        thinking:
          type: enabled

adapters:
  - name: my-tool
    models:
      - sourceModelId: claude-sonnet-4
        provider: anthropic
        targetModelId: claude-sonnet-4-20250514

# 外挂识图 — 为不支持图片的模型自动识图
vision:
  provider: anthropic              # 必填：识图 Provider
  model: claude-sonnet-4           # 必填：多模态模型 ID
  prompt: |                        # 可选：自定义提示词（默认见下）
    请详细描述这张图片的内容，包括其中的文字、物体、场景、颜色等关键信息。
```

API Key 通过环境变量注入（`${VAR}`），配置文件不保存明文密钥。

## 外挂识图

当路由目标模型 **未声明** `input: image` 时，llm-proxy 可以自动把图片内容转成文字描述，再交给模型处理：

1. 入站请求包含图片块（Anthropic `image`、OpenAI Chat `image_url`、OpenAI Responses `input_image`）
2. llm-proxy 提取每张图片，通过代理自身路由调用配置的识图 Provider/Model（递归路由）
3. 把图片块替换为 `<image_description>...</image_description>` 文本块
4. 非多模态模型收到文本，就能"看懂"图片内容

**识图缓存**（`~/.llm-proxy/vision-cache.json`）：
- 键：base64 图片用 `md5:<hash>`，URL 图片用 `url:<原 URL>`
- LRU 淘汰上限 1000 条（可通过 `vision_cache.max_entries` 调整）
- 内存变更后 5s 防抖刷盘；进程退出前同步刷盘
- 统计信息：`hits / misses / hit-rate`，通过 `/admin/vision-cache/stats` 查看

缓存文件位置：`~/.llm-proxy/vision-cache.json`

## CLI 命令

```bash
llm-proxy start     # 启动代理
llm-proxy stop      # 停止代理
llm-proxy restart   # 重启
llm-proxy reload    # 热加载配置（零中断）
llm-proxy status    # 查看状态
```

## 管理 API

| 端点 | 方法 | 说明 |
|----------|--------|-------------|
| `/admin/config` | GET | 查看配置（Key 脱敏） |
| `/admin/config/reload` | POST | 热加载配置 |
| `/admin/health` | GET | 健康检查 |
| `/admin/status/providers` | GET | Provider 状态统计 |
| `/admin/logs` | GET | 请求日志 |
| `/admin/logs/stats` | GET | 日志统计 |
| `/admin/token-stats` | GET | Token 统计 |
| `/admin/log-level` | GET / PUT | 查看 / 修改日志级别 |
| `/admin/locale` | GET / PUT | 查看 / 修改服务端消息语言（zh / en） |
| `/admin/port` | GET / PUT | 查看 / 修改监听端口（运行时热切换） |
| `/admin/auth/handoff` | POST | 使用当前管理密钥签发一次性 Web UI 交接码 |
| `/admin/auth/handoff/exchange` | POST | Web UI 兑换一次性交接码 |
| `/admin/proxy-key` | GET / PUT | 查看 / 修改代理认证密钥 |
| `/admin/vision` | GET / PUT | 查看 / 修改外挂识图配置 |
| `/admin/vision-cache/stats` | GET | 识图缓存命中率 / 容量 |
| `/admin/vision-cache/clear` | POST | 清空识图缓存 |
| `/admin/adapters` | GET / POST / PUT / DELETE | 适配器 CRUD |
| `/admin/providers` | POST / PUT / DELETE | Provider CRUD |
| `/admin/providers/:name/pull-models` | POST | 拉取远程模型列表 |
| `/admin/test-model` | POST | 向 Provider/Model 发送测试请求 |
| `/admin/test-adapter` | POST | 通过适配器发送测试请求 |
| `/admin/debug/captures/*` | GET / POST | 协议抓包环形缓冲 + SSE 推送 |

配置 `admin_key` 后，除静态 `GET /admin` 页面外壳和一次性交接码兑换接口外，管理 API 都必须携带 `Authorization: Bearer <key>` 或 `x-api-key: <key>`。Web UI 会按 origin 和反向代理路径隔离密钥；macOS App 通过 HTTPS 或本机 HTTP 打开 Web UI 时，可使用 60 秒、单次有效的交接码自动导入密钥，原始密钥不会进入 URL。热重载轮换或删除密钥时，需用当前运行密钥鉴权：`llm-proxy reload --admin-key <当前密钥>`（也可设置 `LLM_PROXY_ADMIN_KEY`）。

macOS App 与 Web UI 的界面语言分别保存在本机和当前浏览器，不再修改服务端 `locale`。服务端 `locale` 仅控制服务日志和后端消息语言。

### 反向代理路径

Web UI 会根据当前 `/admin` 页面路径自动解析反向代理前缀。例如将服务发布到 `/llm-proxy/` 时，Nginx 只需正常转发路径：

```nginx
location /llm-proxy/ {
    proxy_pass http://127.0.0.1:9000/;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Authorization $http_authorization;
    proxy_buffering off;
}
```

不要使用 `sub_filter` 全局替换 `/admin`。它会同时改写内联 JavaScript 的正则表达式，导致 Web UI 在加载时出现语法错误。

## 协议转换矩阵

| 源 | 目标 | 非流式 | 流式 (SSE) |
|--------|--------|:---:|:---:|
| Anthropic | OpenAI | ✅ | ✅ |
| OpenAI | Anthropic | ✅ | ✅ |
| Anthropic | OpenAI Responses | ✅ | ✅ |
| OpenAI Responses | Anthropic | ✅ | ✅ |

## 架构

```
Client → POST /v1/{messages|chat/completions|responses}
  → server.ts（正则路由匹配）
    → pipeline.ts（统一请求流水线）
      → parseAndAuth()（body、JSON、认证、提取 model）
      → vision.ts（图片 → 文字（外挂识图），含 LRU 缓存）
      → router.ts（modelName → Provider）
      → translation.ts（协议转换、thinking 注入）
      → provider.ts（fetch 上游）
      → stream-converter.ts（SSE 转换）
      → capture.ts（环形缓冲 + SSE 推送）
      → token-tracker（按 Provider 统计 token 用量）
  → 响应
```

- **运行时**：Node.js >= 20, TypeScript ESM
- **前端**：Alpine.js 单页应用
- **构建**：`tsc` + `esbuild`
- **测试**：Node.js 原生测试运行器 + tsx（280 个测试）

## 开发

详见 [DEVELOPMENT.md](./DEVELOPMENT.md)。

```bash
npm run dev          # 开发模式启动代理
npm test             # 运行 280 个测试
npm run build:app    # 构建 macOS .app + .dmg
```

## FAQ

**Homebrew 安装显示旧版本？** 强制刷新 tap：
```bash
brew upgrade --cask llm-proxy
```
如果失败，先卸载再重新安装：
```bash
brew uninstall --cask llm-proxy
brew untap maplezzk/tap && brew tap maplezzk/tap && brew install --cask llm-proxy
```

**macOS 阻止应用运行？** 清除隔离标记：
```bash
xattr -cr /Applications/LLMProxy.app
```

**怎么清空识图缓存？** 任选其一：
```bash
rm ~/.llm-proxy/vision-cache.json
```
或调用 `POST /admin/vision-cache/clear`（也可以在管理界面 → 识图设置 → 「清空缓存」按钮）。

**识图缓存在哪里？** `~/.llm-proxy/vision-cache.json`，重启后保留，5s 防抖刷盘。

**怎么给模型开启图片输入？** 在管理界面 → Providers → 编辑模型 → 勾选 **输入模态 → 图片**，或在 `config.yaml` 中加 `input: [text, image]`。未声明 `image` 的模型在配置了 `vision:` 后会自动走外挂识图。
