├── .gitignore ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── README_CN.md ├── README_EN.md ├── src ├── cache.rs ├── evert.rs ├── handlers │ ├── admin.rs │ ├── chat.rs │ ├── cors.rs │ ├── limit.rs │ ├── mod.rs │ └── models.rs ├── main.rs ├── poe_client.rs ├── types.rs └── utils.rs ├── static ├── fa-solid-900.ttf ├── fa-solid-900.woff2 ├── fontawesome.css └── tailwind.js └── templates └── admin.html /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | models.yaml -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "poe2openai" 3 | version = "0.5.0" 4 | edition = "2024" 5 | publish = ["crates-io"] 6 | authors = ["Jerome Leong "] 7 | description = "Poe API to OpenAI API" 8 | repository = "https://github.com/jeromeleong/poe2openai" 9 | license = "MIT" 10 | keywords = ["poeapi", "openai","ai"] 11 | 12 | [dependencies] 13 | poe_api_process = "0.2.1" 14 | tokio = { version = "1.45.1", features = ["full"] } 15 | futures-util = "0.3.31" 16 | salvo = { version = "0.79.0", features = ["basic-auth","size-limiter","serve-static","cors"] } 17 | serde = "1.0.219" 18 | serde_json = "1.0.140" 19 | chrono = "0.4.41" 20 | nanoid = "0.4.0" 21 | tracing = "0.1.41" 22 | tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } 23 | askama = "0.14.0" 24 | serde_yaml = "0.9.34" 25 | tiktoken-rs = "0.7.0" 26 | tempfile = "3.20.0" 27 | base64 = "0.22.1" 28 | regex = "1.11.1" 29 | sled = { version = "0.34.7", features = ["no_logs"] } 30 | sha2 = "0.10.9" 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 第一階段:建構環境 2 | FROM rustlang/rust:nightly-slim AS builder 3 | 4 | # 設定建構時的環境變數 5 | ENV CARGO_TERM_COLOR=always \ 6 | CARGO_NET_GIT_FETCH_WITH_CLI=true \ 7 | CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse 8 | 9 | # 安裝建構依賴 10 | RUN apt-get update && \ 11 | apt-get install -y --no-install-recommends \ 12 | pkg-config \ 13 | libssl-dev \ 14 | && rm -rf /var/lib/apt/lists/* 15 | 16 | # 設置工作目錄 17 | WORKDIR /usr/src/app 18 | 19 | # 複製 Cargo.toml 20 | COPY Cargo.toml ./ 21 | 22 | # 建立虛擬的 src 目錄和主檔案以緩存依賴 23 | RUN mkdir src && \ 24 | echo "fn main() {}" > src/main.rs 25 | 26 | # 建構依賴項 27 | RUN cargo build --release 28 | 29 | # 移除虛擬的 src 目錄和建構檔案 30 | RUN rm -rf src target/release/deps/poe2openai* target/release/poe2openai* 31 | 32 | # 複製實際的源碼和資源文件 33 | COPY src ./src 34 | COPY templates ./templates 35 | COPY static ./static 36 | 37 | # 重新建構專案 38 | RUN cargo build --release 39 | 40 | # 第二階段:執行環境 41 | FROM debian:bookworm-slim 42 | 43 | # 設定執行時的環境變數 44 | ENV HOST=0.0.0.0 \ 45 | PORT=8080 \ 46 | ADMIN_USERNAME=admin \ 47 | ADMIN_PASSWORD=123456 \ 48 | MAX_REQUEST_SIZE=1073741824 \ 49 | LOG_LEVEL=info \ 50 | RUST_BACKTRACE=1 \ 51 | TZ=Asia/Taipei \ 52 | CONFIG_DIR=/data 53 | 54 | # 安裝執行時期依賴 55 | RUN apt-get update && \ 56 | apt-get install -y --no-install-recommends \ 57 | ca-certificates \ 58 | libssl3 \ 59 | curl \ 60 | tzdata \ 61 | && rm -rf /var/lib/apt/lists/* \ 62 | && ln -sf /usr/share/zoneinfo/$TZ /etc/localtime \ 63 | && echo $TZ > /etc/timezone 64 | 65 | # 建立非 root 使用者 66 | RUN groupadd -r poe && useradd -r -g poe poe 67 | 68 | # 建立應用程式目錄 69 | WORKDIR /app 70 | 71 | # 從建構階段複製編譯好的二進制檔案和資源文件 72 | COPY --from=builder /usr/src/app/target/release/poe2openai /app/ 73 | COPY --from=builder /usr/src/app/templates /app/templates 74 | COPY --from=builder /usr/src/app/static /app/static 75 | 76 | # 創建數據目錄並設置權限 77 | RUN mkdir -p /data && chown poe:poe /data && chmod 775 /data 78 | 79 | # 設定檔案權限 80 | RUN chown -R poe:poe /app 81 | 82 | # 定義volume掛載點 83 | VOLUME ["/data"] 84 | 85 | # 切換到非 root 使用者 86 | USER poe 87 | 88 | # 設定容器啟動指令 89 | ENTRYPOINT ["/app/poe2openai"] 90 | 91 | # 暴露端口 92 | EXPOSE ${PORT} 93 | 94 | # 設定標籤 95 | LABEL maintainer="Jerome Leong " \ 96 | description="Poe API to OpenAI API 轉換服務" \ 97 | version="0.5.0" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Jerome Leong 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🔄 POE to OpenAI API 2 | 3 | [![Rust](https://img.shields.io/badge/rust-1.75%2B-orange.svg)](https://www.rust-lang.org) 4 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) 5 | [![Docker Version](https://img.shields.io/docker/v/jeromeleong/poe2openai?sort=semver)](https://hub.docker.com/r/jeromeleong/poe2openai) 6 | [![Docker Size](https://img.shields.io/docker/image-size/jeromeleong/poe2openai/latest 7 | )](https://hub.docker.com/r/jeromeleong/poe2openai) 8 | [![Docker Pulls](https://img.shields.io/docker/pulls/jeromeleong/poe2openai)](https://hub.docker.com/r/jeromeleong/poe2openai) 9 | 10 | [ [English](https://github.com/jeromeleong/poe2openai/blob/master/README_EN.md) | [繁體中文](https://github.com/jeromeleong/poe2openai/blob/master/README.md) | [简体中文](https://github.com/jeromeleong/poe2openai/blob/master/README_CN.md) ] 11 | 12 | Poe2OpenAI 是一個將 POE API 轉換為 OpenAI API 格式的代理服務。讓 Poe 訂閱者能夠通過 OpenAI API 格式使用 Poe 的各種 AI 模型。 13 | 14 | ## 📑 目錄 15 | - [主要特點](#-主要特點) 16 | - [安裝指南](#-安裝指南) 17 | - [快速開始](#-快速開始) 18 | - [API 文檔](#-api-文檔) 19 | - [配置說明](#️-配置說明) 20 | - [常見問題](#-常見問題) 21 | - [貢獻指南](#-貢獻指南) 22 | - [授權協議](#-授權協議) 23 | 24 | ## ✨ 主要特點 25 | - 🔄 支持 OpenAI API 格式(`/models` 和 `/chat/completions`) 26 | - 💬 支持串流和非串流模式 27 | - 🔧 支持工具調用 (Tool Calls) 28 | - 🖼️ 支持文件上傳並加入對話 (URL 和 Base64) 29 | - 🌐 對最新 POE API 的 Event 進行完整處理 30 | - 🤖 支持 Claude/Roo Code 解析,包括 Token 用量統計 31 | - 📊 Web 管理介面(`/admin`)用於配置模型(模型映射和編輯`/models`顯示的模型) 32 | - 🔒 支持速率限制控制,防止請求過於頻繁 33 | - 📦 內建 URL 和 Base64 圖片緩存系統,減少重複上傳 34 | - 🐳 Docker 佈置支持 35 | 36 | ## 🔧 安裝指南 37 | 38 | ### 使用 Docker(簡單部署) 39 | ```bash 40 | # 拉取映像 41 | docker pull jeromeleong/poe2openai:latest 42 | 43 | # 運行容器 44 | docker run --name poe2openai -d \ 45 | -p 8080:8080 \ 46 | -e ADMIN_USERNAME=admin \ 47 | -e ADMIN_PASSWORD=123456 \ 48 | jeromeleong/poe2openai:latest 49 | ``` 50 | 51 | #### 數據持久化(可選) 52 | ```bash 53 | # 創建本地數據目錄 54 | mkdir -p /path/to/data 55 | 56 | # 運行容器並掛載數據目錄 57 | docker run --name poe2openai -d \ 58 | -p 8080:8080 \ 59 | -v /path/to/data:/data \ 60 | -e CONFIG_DIR=/data \ 61 | -e ADMIN_USERNAME=admin \ 62 | -e ADMIN_PASSWORD=123456 \ 63 | jeromeleong/poe2openai:latest 64 | ``` 65 | 66 | ### 使用 Docker Compose 67 | 具體內容可根據自己個人需求來進行修改 68 | ```yaml 69 | version: '3.8' 70 | services: 71 | poe2openai: 72 | image: jeromeleong/poe2openai:latest 73 | ports: 74 | - "8080:8080" 75 | environment: 76 | - PORT=8080 77 | - LOG_LEVEL=info 78 | - ADMIN_USERNAME=admin 79 | - ADMIN_PASSWORD=123456 80 | - MAX_REQUEST_SIZE=1073741824 81 | - CONFIG_DIR=/data 82 | - RATE_LIMIT_MS=100 83 | - URL_CACHE_TTL_SECONDS=259200 84 | - URL_CACHE_SIZE_MB=100 85 | volumes: 86 | - /path/to/data:/data 87 | ``` 88 | 89 | ### 從源碼編譯 90 | ```bash 91 | # 克隆專案 92 | git clone https://github.com/jeromeleong/poe2openai 93 | cd poe2openai 94 | 95 | # 編譯 96 | cargo build --release 97 | 98 | # 運行 99 | ./target/release/poe2openai 100 | ``` 101 | 102 | ## 🚀 快速開始 103 | 104 | 1. 使用 Docker 啟動服務: 105 | ```bash 106 | docker run -d -p 8080:8080 jeromeleong/poe2openai:latest 107 | ``` 108 | 109 | 2. 服務器默認在 `http://localhost:8080` 啟動 110 | 111 | 3. 使用方式示例: 112 | ```bash 113 | curl http://localhost:8080/v1/chat/completions \ 114 | -H "Content-Type: application/json" \ 115 | -H "Authorization: Bearer your-poe-token" \ 116 | -d '{ 117 | "model": "gpt-4o-mini", 118 | "messages": [{"role": "user", "content": "你好"}], 119 | "stream": true 120 | }' 121 | ``` 122 | 123 | 4. 可以在 `http://localhost:8080/admin` 管理模型 124 | 125 | ## 📖 API 文檔 126 | 127 | ### 支援的 OpenAI API 端點 128 | - `GET /v1/models` - 獲取可用模型列表 129 | - `POST /v1/chat/completions` - 與 POE 模型聊天 130 | - `GET /models` - 獲取可用模型列表(相容端點) 131 | - `POST /chat/completions` - 與 POE 模型聊天(相容端點) 132 | 133 | ### 請求格式 134 | ```json 135 | { 136 | "model": "string", 137 | "messages": [ 138 | { 139 | "role": "user", 140 | "content": "string" 141 | } 142 | ], 143 | "temperature": 0.7, 144 | "stream": false, 145 | "tools": [], 146 | "stream_options": { 147 | "include_usage": false 148 | } 149 | } 150 | ``` 151 | 152 | #### 可選參數說明 153 | | 參數 | 類型 | 預設值 | 說明 | 154 | |---------------|----------|--------------|------------------------------------------------------| 155 | | model | string | (必填) | 要請求的模型名稱 | 156 | | messages | array | (必填) | 聊天訊息列表,陣列內須有 role 與 content | 157 | | temperature | float | null | 探索性(0~2)。控制回答的多樣性,數值越大越發散 | 158 | | stream | bool | false | 是否串流回傳(SSE),true 開啟串流 | 159 | | tools | array | null | 工具描述 (Tool Calls) 支援(如 function calling) | 160 | | logit_bias | object | null | 特定 token 的偏好值 | 161 | | stop | array | null | 停止生成的文本序列 | 162 | | stream_options| object | null | 串流細部選項,目前支援 {"include_usage": bool}: 是否附帶用量統計| 163 | 164 | > 其他參數如 top_p、n 等 OpenAI 參數暫不支援,提交會被忽略。 165 | 166 | ### 響應格式 167 | ```json 168 | { 169 | "id": "chatcmpl-xxx", 170 | "object": "chat.completion", 171 | "created": 1677858242, 172 | "model": "gpt-4o-mini", 173 | "choices": [ 174 | { 175 | "index": 0, 176 | "message": { 177 | "role": "assistant", 178 | "content": "回應內容" 179 | }, 180 | "finish_reason": "stop" 181 | } 182 | ], 183 | "usage": { 184 | "prompt_tokens": 10, 185 | "completion_tokens": 20, 186 | "total_tokens": 30, 187 | "prompt_tokens_details": { 188 | "cached_tokens": 0 189 | } 190 | } 191 | } 192 | ``` 193 | 194 | ### 多模態請求範例 195 | ```json 196 | { 197 | "model": "claude-3-opus", 198 | "messages": [ 199 | { 200 | "role": "user", 201 | "content": [ 202 | { 203 | "type": "text", 204 | "text": "這張圖片是什麼?" 205 | }, 206 | { 207 | "type": "image_url", 208 | "image_url": { 209 | "url": "https://example.com/image.jpg" 210 | } 211 | } 212 | ] 213 | } 214 | ] 215 | } 216 | ``` 217 | 218 | ## ⚙️ 配置說明 219 | 服務器配置通過環境變量進行: 220 | - `PORT` - 服務器端口(默認:`8080`) 221 | - `HOST` - 服務器主機(默認:`0.0.0.0`) 222 | - `ADMIN_USERNAME` - 管理介面用戶名(默認:`admin`) 223 | - `ADMIN_PASSWORD` - 管理介面密碼(默認:`123456`) 224 | - `MAX_REQUEST_SIZE` - 最大請求大小(默認:`1073741824`,1GB) 225 | - `LOG_LEVEL` - 日誌級別(默認:`info`,可選:`debug`, `info`, `warn`, `error`) 226 | - `CONFIG_DIR` - 配置文件目錄路徑(docker 環境中默認為:`/data`,本機環境中默認為:`./`) 227 | - `RATE_LIMIT_MS` - 全局速率限制(毫秒,默認:`100`,設置為 `0` 禁用) 228 | - `URL_CACHE_TTL_SECONDS` - Poe CDN URL緩存有效期(秒,默認:`259200`,3天) 229 | - `URL_CACHE_SIZE_MB` - Poe CDN URL緩存最大容量(MB,默認:`100`) 230 | 231 | ## ❓ 常見問題 232 | 233 | ### Q: Poe API Token 如何獲取? 234 | A: 首先要訂閱 Poe,才能從 [Poe API Key](https://poe.com/api_key) 網頁中取得。 235 | 236 | ### Q: 為什麼會收到認證錯誤? 237 | A: 確保在請求頭中正確設置了 `Authorization: Bearer your-poe-token`。 238 | 239 | ### Q: 支援哪些模型? 240 | A: 支援所有 POE 平台上可用的模型,可通過 `/v1/models` 端點查詢。 241 | 242 | ### Q: 如何修改服務器端口? 243 | A: 可以通過設置環境變量 `PORT` 來修改,例如: 244 | ```bash 245 | docker run -d -e PORT=3000 -p 3000:3000 jeromeleong/poe2openai:latest 246 | ``` 247 | 248 | ### Q: 如何使用 models.yaml 配置模型? 249 | A: 在管理介面 `/admin` 頁面中可以進行模型配置,也可以手動編輯 `CONFIG_DIR` 目錄下的 `models.yaml` 文件。 250 | 251 | ### Q: 如何處理請求頻率限制? 252 | A: 可以通過設置環境變量 `RATE_LIMIT_MS` 來控制請求間隔,單位為毫秒。設置為 `0` 則禁用限制。 253 | 254 | ## 🤝 貢獻指南 255 | 歡迎所有形式的貢獻!如果您發現了問題或有改進建議,請提交 Issue 或 Pull Request。 256 | 257 | ## 📄 授權協議 258 | 本專案使用 [MIT 授權協議](LICENSE)。 259 | 260 | ## 🌟 Star 歷史 261 | [![Star History Chart](https://api.star-history.com/svg?repos=jeromeleong/poe2openai&type=Date)](https://star-history.com/#jeromeleong/poe2openai&Date) -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 | # 🔄 POE to OpenAI API 2 | 3 | [![Rust](https://img.shields.io/badge/rust-1.75%2B-orange.svg)](https://www.rust-lang.org) 4 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) 5 | [![Docker Version](https://img.shields.io/docker/v/jeromeleong/poe2openai?sort=semver)](https://hub.docker.com/r/jeromeleong/poe2openai) 6 | [![Docker Size](https://img.shields.io/docker/image-size/jeromeleong/poe2openai/latest 7 | )](https://hub.docker.com/r/jeromeleong/poe2openai) 8 | [![Docker Pulls](https://img.shields.io/docker/pulls/jeromeleong/poe2openai)](https://hub.docker.com/r/jeromeleong/poe2openai) 9 | 10 | Poe2OpenAI 是一个将 POE API 转换为 OpenAI API 格式的代理服务。让 Poe 订阅者能够通过 OpenAI API 格式使用 Poe 的各种 AI 模型。 11 | 12 | ## 📑 目录 13 | - [主要特点](#-主要特点) 14 | - [安装指南](#-安装指南) 15 | - [快速开始](#-快速开始) 16 | - [API 文档](#-api-文档) 17 | - [配置说明](#️-配置说明) 18 | - [常见问题](#-常见问题) 19 | - [贡献指南](#-贡献指南) 20 | - [授权协议](#-授权协议) 21 | 22 | ## ✨ 主要特点 23 | - 🔄 支持 OpenAI API 格式(`/models` 和 `/chat/completions`) 24 | - 💬 支持流式和非流式模式 25 | - 🔧 支持工具调用 (Tool Calls) 26 | - 🖼️ 支持文件上传并加入对话 (URL 和 Base64) 27 | - 🌐 对最新 POE API 的 Event 进行完整处理 28 | - 🤖 支持 Claude/Roo Code 解析,包括 Token 用量统计 29 | - 📊 Web 管理界面(`/admin`)用于配置模型(模型映射和编辑`/models`显示的模型) 30 | - 🔒 支持速率限制控制,防止请求过于频繁 31 | - 📦 内置 URL 和 Base64 图片缓存系统,减少重复上载 32 | - 🐳 Docker 部署支持 33 | 34 | ## 🔧 安装指南 35 | ### 使用 Docker(简单部署) 36 | ```bash 37 | # 拉取镜像 38 | docker pull jeromeleong/poe2openai:latest 39 | # 运行容器 40 | docker run --name poe2openai -d \ 41 | -p 8080:8080 \ 42 | -e ADMIN_USERNAME=admin \ 43 | -e ADMIN_PASSWORD=123456 \ 44 | jeromeleong/poe2openai:latest 45 | ``` 46 | 47 | #### 数据持久化(可选) 48 | ```bash 49 | # 创建本地数据目录 50 | mkdir -p /path/to/data 51 | # 运行容器并挂载数据目录 52 | docker run --name poe2openai -d \ 53 | -p 8080:8080 \ 54 | -v /path/to/data:/data \ 55 | -e CONFIG_DIR=/data \ 56 | -e ADMIN_USERNAME=admin \ 57 | -e ADMIN_PASSWORD=123456 \ 58 | jeromeleong/poe2openai:latest 59 | ``` 60 | 61 | ### 使用 Docker Compose 62 | 具体内容可根据自己个人需求来进行修改 63 | ```yaml 64 | version: '3.8' 65 | services: 66 | poe2openai: 67 | image: jeromeleong/poe2openai:latest 68 | ports: 69 | - "8080:8080" 70 | environment: 71 | - PORT=8080 72 | - LOG_LEVEL=info 73 | - ADMIN_USERNAME=admin 74 | - ADMIN_PASSWORD=123456 75 | - MAX_REQUEST_SIZE=1073741824 76 | - CONFIG_DIR=/data 77 | - RATE_LIMIT_MS=100 78 | - URL_CACHE_TTL_SECONDS=259200 79 | - URL_CACHE_SIZE_MB=100 80 | volumes: 81 | - /path/to/data:/data 82 | ``` 83 | 84 | ### 从源码编译 85 | ```bash 86 | # 克隆项目 87 | git clone https://github.com/jeromeleong/poe2openai 88 | cd poe2openai 89 | # 编译 90 | cargo build --release 91 | # 运行 92 | ./target/release/poe2openai 93 | ``` 94 | 95 | ## 🚀 快速开始 96 | 1. 使用 Docker 启动服务: 97 | ```bash 98 | docker run -d -p 8080:8080 jeromeleong/poe2openai:latest 99 | ``` 100 | 2. 服务器默认在 `http://localhost:8080` 启动 101 | 3. 使用方式示例: 102 | ```bash 103 | curl http://localhost:8080/v1/chat/completions \ 104 | -H "Content-Type: application/json" \ 105 | -H "Authorization: Bearer your-poe-token" \ 106 | -d '{ 107 | "model": "gpt-4o-mini", 108 | "messages": [{"role": "user", "content": "你好"}], 109 | "stream": true 110 | }' 111 | ``` 112 | 4. 可以在 `http://localhost:8080/admin` 管理模型 113 | 114 | ## 📖 API 文档 115 | ### 支持的 OpenAI API 端点 116 | - `GET /v1/models` - 获取可用模型列表 117 | - `POST /v1/chat/completions` - 与 POE 模型聊天 118 | - `GET /models` - 获取可用模型列表(兼容端点) 119 | - `POST /chat/completions` - 与 POE 模型聊天(兼容端点) 120 | 121 | ### 请求格式 122 | ```json 123 | { 124 | "model": "string", 125 | "messages": [ 126 | { 127 | "role": "user", 128 | "content": "string" 129 | } 130 | ], 131 | "temperature": 0.7, 132 | "stream": false, 133 | "tools": [], 134 | "stream_options": { 135 | "include_usage": false 136 | } 137 | } 138 | ``` 139 | 140 | #### 可选参数说明 141 | | 参数 | 类型 | 默认值 | 说明 | 142 | |---------------|----------|--------------|------------------------------------------------------| 143 | | model | string | (必填) | 要请求的模型名称 | 144 | | messages | array | (必填) | 聊天消息列表,数组内须有 role 与 content | 145 | | temperature | float | null | 探索性(0~2)。控制回答的多样性,数值越大越发散 | 146 | | stream | bool | false | 是否流式返回(SSE),true 开启流式 | 147 | | tools | array | null | 工具描述 (Tool Calls) 支持(如 function calling) | 148 | | logit_bias | object | null | 特定 token 的偏好值 | 149 | | stop | array | null | 停止生成的文本序列 | 150 | | stream_options| object | null | 流式细部选项,目前支持 {"include_usage": bool}: 是否附带用量统计| 151 | 152 | > 其他参数如 top_p、n 等 OpenAI 参数暂不支持,提交会被忽略。 153 | 154 | ### 响应格式 155 | ```json 156 | { 157 | "id": "chatcmpl-xxx", 158 | "object": "chat.completion", 159 | "created": 1677858242, 160 | "model": "gpt-4o-mini", 161 | "choices": [ 162 | { 163 | "index": 0, 164 | "message": { 165 | "role": "assistant", 166 | "content": "响应内容" 167 | }, 168 | "finish_reason": "stop" 169 | } 170 | ], 171 | "usage": { 172 | "prompt_tokens": 10, 173 | "completion_tokens": 20, 174 | "total_tokens": 30, 175 | "prompt_tokens_details": { 176 | "cached_tokens": 0 177 | } 178 | } 179 | } 180 | ``` 181 | 182 | ### 多模态请求范例 183 | ```json 184 | { 185 | "model": "claude-3-opus", 186 | "messages": [ 187 | { 188 | "role": "user", 189 | "content": [ 190 | { 191 | "type": "text", 192 | "text": "这张图片是什么?" 193 | }, 194 | { 195 | "type": "image_url", 196 | "image_url": { 197 | "url": "https://example.com/image.jpg" 198 | } 199 | } 200 | ] 201 | } 202 | ] 203 | } 204 | ``` 205 | 206 | ## ⚙️ 配置说明 207 | 服务器配置通过环境变量进行: 208 | - `PORT` - 服务器端口(默认:`8080`) 209 | - `HOST` - 服务器主机(默认:`0.0.0.0`) 210 | - `ADMIN_USERNAME` - 管理界面用户名(默认:`admin`) 211 | - `ADMIN_PASSWORD` - 管理界面密码(默认:`123456`) 212 | - `MAX_REQUEST_SIZE` - 最大请求大小(默认:`1073741824`,1GB) 213 | - `LOG_LEVEL` - 日志级别(默认:`info`,可选:`debug`, `info`, `warn`, `error`) 214 | - `CONFIG_DIR` - 配置文件目录路径(docker 环境中默认为:`/data`,本机环境中默认为:`./`) 215 | - `RATE_LIMIT_MS` - 全局速率限制(毫秒,默认:`100`,设置为 `0` 禁用) 216 | - `URL_CACHE_TTL_SECONDS` - Poe CDN URL缓存有效期(秒,默认:`259200`,3天) 217 | - `URL_CACHE_SIZE_MB` - Poe CDN URL缓存最大容量(MB,默认:`100`) 218 | 219 | ## ❓ 常见问题 220 | ### Q: Poe API Token 如何获取? 221 | A: 首先要订阅 Poe,才能从 [Poe API Key](https://poe.com/api_key) 网页中获取。 222 | 223 | ### Q: 为什么会收到认证错误? 224 | A: 确保在请求头中正确设置了 `Authorization: Bearer your-poe-token`。 225 | 226 | ### Q: 支持哪些模型? 227 | A: 支持所有 POE 平台上可用的模型,可通过 `/v1/models` 端点查询。 228 | 229 | ### Q: 如何修改服务器端口? 230 | A: 可以通过设置环境变量 `PORT` 来修改,例如: 231 | ```bash 232 | docker run -d -e PORT=3000 -p 3000:3000 jeromeleong/poe2openai:latest 233 | ``` 234 | 235 | ### Q: 如何使用 models.yaml 配置模型? 236 | A: 在管理界面 `/admin` 页面中可以进行模型配置,也可以手动编辑 `CONFIG_DIR` 目录下的 `models.yaml` 文件。 237 | 238 | ### Q: 如何处理请求频率限制? 239 | A: 可以通过设置环境变量 `RATE_LIMIT_MS` 来控制请求间隔,单位为毫秒。设置为 `0` 则禁用限制。 240 | 241 | ## 🤝 贡献指南 242 | 欢迎所有形式的贡献!如果您发现了问题或有改进建议,请提交 Issue 或 Pull Request。 243 | 244 | ## 📄 授权协议 245 | 本项目使用 [MIT 授权协议](LICENSE)。 246 | 247 | ## 🌟 Star 历史 248 | [![Star History Chart](https://api.star-history.com/svg?repos=jeromeleong/poe2openai&type=Date)](https://star-history.com/#jeromeleong/poe2openai&Date) -------------------------------------------------------------------------------- /README_EN.md: -------------------------------------------------------------------------------- 1 | # 🔄 POE to OpenAI API 2 | 3 | [![Rust](https://img.shields.io/badge/rust-1.75%2B-orange.svg)](https://www.rust-lang.org) 4 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) 5 | [![Docker Version](https://img.shields.io/docker/v/jeromeleong/poe2openai?sort=semver)](https://hub.docker.com/r/jeromeleong/poe2openai) 6 | [![Docker Size](https://img.shields.io/docker/image-size/jeromeleong/poe2openai/latest 7 | )](https://hub.docker.com/r/jeromeleong/poe2openai) 8 | [![Docker Pulls](https://img.shields.io/docker/pulls/jeromeleong/poe2openai)](https://hub.docker.com/r/jeromeleong/poe2openai) 9 | 10 | [ [English](https://github.com/jeromeleong/poe2openai/blob/master/README_EN.md) | [繁體中文](https://github.com/jeromeleong/poe2openai/blob/master/README.md) | [简体中文](https://github.com/jeromeleong/poe2openai/blob/master/README_CN.md) ] 11 | 12 | Poe2OpenAI is a proxy service that converts the POE API to OpenAI API format. It allows Poe subscribers to use various AI models on Poe through the OpenAI API format. 13 | 14 | ## 📑 Table of Contents 15 | - [Key Features](#-key-features) 16 | - [Installation Guide](#-installation-guide) 17 | - [Quick Start](#-quick-start) 18 | - [API Documentation](#-api-documentation) 19 | - [Configuration](#️-configuration) 20 | - [FAQ](#-faq) 21 | - [Contributing](#-contributing) 22 | - [License](#-license) 23 | 24 | ## ✨ Key Features 25 | - 🔄 Support for OpenAI API format (`/models` and `/chat/completions`) 26 | - 💬 Support for streaming and non-streaming modes 27 | - 🔧 Support for Tool Calls 28 | - 🖼️ Support for file uploads in conversations (URL and Base64) 29 | - 🌐 Complete handling of Events from the latest POE API 30 | - 🤖 Support for Claude/Roo Code parsing, including token usage statistics 31 | - 📊 Web admin interface (`/admin`) for model configuration (model mapping and editing models displayed in `/models`) 32 | - 🔒 Rate limiting support to prevent excessive requests 33 | - 📦 Built-in URL and Base64 files caching system to reduce duplicate uploads 34 | - 🐳 Docker deployment support 35 | 36 | ## 🔧 Installation Guide 37 | ### Using Docker (Simple Deployment) 38 | ```bash 39 | # Pull the image 40 | docker pull jeromeleong/poe2openai:latest 41 | # Run the container 42 | docker run --name poe2openai -d \ 43 | -p 8080:8080 \ 44 | -e ADMIN_USERNAME=admin \ 45 | -e ADMIN_PASSWORD=123456 \ 46 | jeromeleong/poe2openai:latest 47 | ``` 48 | 49 | #### Data Persistence (Optional) 50 | ```bash 51 | # Create local data directory 52 | mkdir -p /path/to/data 53 | # Run container with mounted data directory 54 | docker run --name poe2openai -d \ 55 | -p 8080:8080 \ 56 | -v /path/to/data:/data \ 57 | -e CONFIG_DIR=/data \ 58 | -e ADMIN_USERNAME=admin \ 59 | -e ADMIN_PASSWORD=123456 \ 60 | jeromeleong/poe2openai:latest 61 | ``` 62 | 63 | ### Using Docker Compose 64 | Modify according to your personal requirements 65 | ```yaml 66 | version: '3.8' 67 | services: 68 | poe2openai: 69 | image: jeromeleong/poe2openai:latest 70 | ports: 71 | - "8080:8080" 72 | environment: 73 | - PORT=8080 74 | - LOG_LEVEL=info 75 | - ADMIN_USERNAME=admin 76 | - ADMIN_PASSWORD=123456 77 | - MAX_REQUEST_SIZE=1073741824 78 | - CONFIG_DIR=/data 79 | - RATE_LIMIT_MS=100 80 | - URL_CACHE_TTL_SECONDS=259200 81 | - URL_CACHE_SIZE_MB=100 82 | volumes: 83 | - /path/to/data:/data 84 | ``` 85 | 86 | ### Building from Source 87 | ```bash 88 | # Clone the repository 89 | git clone https://github.com/jeromeleong/poe2openai 90 | cd poe2openai 91 | # Build 92 | cargo build --release 93 | # Run 94 | ./target/release/poe2openai 95 | ``` 96 | 97 | ## 🚀 Quick Start 98 | 1. Start the service using Docker: 99 | ```bash 100 | docker run -d -p 8080:8080 jeromeleong/poe2openai:latest 101 | ``` 102 | 2. The server starts by default at `http://localhost:8080` 103 | 3. Usage example: 104 | ```bash 105 | curl http://localhost:8080/v1/chat/completions \ 106 | -H "Content-Type: application/json" \ 107 | -H "Authorization: Bearer your-poe-token" \ 108 | -d '{ 109 | "model": "gpt-4o-mini", 110 | "messages": [{"role": "user", "content": "Hello"}], 111 | "stream": true 112 | }' 113 | ``` 114 | 4. You can manage models at `http://localhost:8080/admin` 115 | 116 | ## 📖 API Documentation 117 | ### Supported OpenAI API Endpoints 118 | - `GET /v1/models` - Get list of available models 119 | - `POST /v1/chat/completions` - Chat with POE models 120 | - `GET /models` - Get list of available models (compatibility endpoint) 121 | - `POST /chat/completions` - Chat with POE models (compatibility endpoint) 122 | 123 | ### Request Format 124 | ```json 125 | { 126 | "model": "string", 127 | "messages": [ 128 | { 129 | "role": "user", 130 | "content": "string" 131 | } 132 | ], 133 | "temperature": 0.7, 134 | "stream": false, 135 | "tools": [], 136 | "stream_options": { 137 | "include_usage": false 138 | } 139 | } 140 | ``` 141 | 142 | #### Optional Parameters 143 | | Parameter | Type | Default | Description | 144 | |---------------|----------|--------------|------------------------------------------------------| 145 | | model | string | (required) | Name of the model to request | 146 | | messages | array | (required) | List of chat messages, each with role and content | 147 | | temperature | float | null | Exploration (0~2). Controls response diversity | 148 | | stream | bool | false | Whether to stream the response (SSE) | 149 | | tools | array | null | Tool descriptions (Tool Calls) support | 150 | | logit_bias | object | null | Token preference values | 151 | | stop | array | null | Sequences that stop text generation | 152 | | stream_options| object | null | Streaming options, supports {"include_usage": bool}: whether to include usage statistics| 153 | 154 | > Other OpenAI parameters like top_p, n, etc. are not currently supported and will be ignored if submitted. 155 | 156 | ### Response Format 157 | ```json 158 | { 159 | "id": "chatcmpl-xxx", 160 | "object": "chat.completion", 161 | "created": 1677858242, 162 | "model": "gpt-4o-mini", 163 | "choices": [ 164 | { 165 | "index": 0, 166 | "message": { 167 | "role": "assistant", 168 | "content": "Response content" 169 | }, 170 | "finish_reason": "stop" 171 | } 172 | ], 173 | "usage": { 174 | "prompt_tokens": 10, 175 | "completion_tokens": 20, 176 | "total_tokens": 30, 177 | "prompt_tokens_details": { 178 | "cached_tokens": 0 179 | } 180 | } 181 | } 182 | ``` 183 | 184 | ### Multimodal Request Example 185 | ```json 186 | { 187 | "model": "claude-3-opus", 188 | "messages": [ 189 | { 190 | "role": "user", 191 | "content": [ 192 | { 193 | "type": "text", 194 | "text": "What's in this image?" 195 | }, 196 | { 197 | "type": "image_url", 198 | "image_url": { 199 | "url": "https://example.com/image.jpg" 200 | } 201 | } 202 | ] 203 | } 204 | ] 205 | } 206 | ``` 207 | 208 | ## ⚙️ Configuration 209 | Server configuration via environment variables: 210 | - `PORT` - Server port (default: `8080`) 211 | - `HOST` - Server host (default: `0.0.0.0`) 212 | - `ADMIN_USERNAME` - Admin interface username (default: `admin`) 213 | - `ADMIN_PASSWORD` - Admin interface password (default: `123456`) 214 | - `MAX_REQUEST_SIZE` - Maximum request size (default: `1073741824`, 1GB) 215 | - `LOG_LEVEL` - Log level (default: `info`, options: `debug`, `info`, `warn`, `error`) 216 | - `CONFIG_DIR` - Configuration file directory (default in Docker: `/data`, default locally: `./`) 217 | - `RATE_LIMIT_MS` - Global rate limit (milliseconds, default: `100`, set to `0` to disable) 218 | - `URL_CACHE_TTL_SECONDS` - Poe CDN URL cache expiration period (seconds, default: `259200`, 3 days) 219 | - `URL_CACHE_SIZE_MB` - Maximum Poe CDN URL cache capacity (MB, default: `100`) 220 | 221 | ## ❓ FAQ 222 | ### Q: How do I get a Poe API Token? 223 | A: You need to subscribe to Poe first, then obtain it from the [Poe API Key](https://poe.com/api_key) page. 224 | 225 | ### Q: Why am I getting authentication errors? 226 | A: Make sure you correctly set the `Authorization: Bearer your-poe-token` in the request headers. 227 | 228 | ### Q: Which models are supported? 229 | A: All models available on the POE platform are supported. You can query them via the `/v1/models` endpoint. 230 | 231 | ### Q: How do I change the server port? 232 | A: You can modify it by setting the `PORT` environment variable, for example: 233 | ```bash 234 | docker run -d -e PORT=3000 -p 3000:3000 jeromeleong/poe2openai:latest 235 | ``` 236 | 237 | ### Q: How do I configure models using models.yaml? 238 | A: You can configure models in the admin interface at `/admin`, or manually edit the `models.yaml` file in the `CONFIG_DIR` directory. 239 | 240 | ### Q: How do I handle request rate limits? 241 | A: You can control the request interval by setting the `RATE_LIMIT_MS` environment variable in milliseconds. Set to `0` to disable limits. 242 | 243 | ## 🤝 Contributing 244 | All forms of contribution are welcome! If you find issues or have suggestions for improvements, please submit an Issue or Pull Request. 245 | 246 | ## 📄 License 247 | This project is licensed under the [MIT License](LICENSE). 248 | 249 | ## 🌟 Star History 250 | [![Star History Chart](https://api.star-history.com/svg?repos=jeromeleong/poe2openai&type=Date)](https://star-history.com/#jeromeleong/poe2openai&Date) -------------------------------------------------------------------------------- /src/cache.rs: -------------------------------------------------------------------------------- 1 | use crate::types::Config; 2 | use crate::utils::load_config_from_yaml; 3 | use std::sync::Arc; 4 | use std::sync::OnceLock; 5 | use std::time::{Duration, SystemTime, UNIX_EPOCH}; 6 | use tracing::debug; 7 | use tracing::{error, info, warn}; 8 | 9 | /// 全域 Sled DB 10 | pub static SLED_DB: OnceLock = OnceLock::new(); 11 | 12 | /// 取得 in-memory sled::Db,僅一次初始化 13 | pub fn get_sled_db() -> &'static sled::Db { 14 | SLED_DB.get_or_init(|| { 15 | sled::Config::new() 16 | .temporary(true) 17 | .open() 18 | .expect("無法初始化 sled 記憶體緩存") 19 | }) 20 | } 21 | 22 | /// 存 config 進 sled 23 | pub fn save_config_sled(key: &str, config: &Config) -> Result<(), String> { 24 | let db = get_sled_db(); 25 | match serde_json::to_vec(config) { 26 | Ok(bytes) => { 27 | db.insert(key.as_bytes(), bytes) 28 | .map_err(|e| format!("寫入 Sled 緩存失敗:{}", e))?; 29 | db.flush().ok(); 30 | Ok(()) 31 | } 32 | Err(e) => Err(format!("序列化設定失敗: {}", e)), 33 | } 34 | } 35 | 36 | /// 讀 config 37 | pub fn load_config_sled(key: &str) -> Result>, String> { 38 | let db = get_sled_db(); 39 | match db.get(key.as_bytes()) { 40 | Ok(Some(bytes)) => match serde_json::from_slice::(&bytes) { 41 | Ok(conf) => Ok(Some(Arc::new(conf))), 42 | Err(e) => { 43 | error!("❌ Sled 解析設定失敗: {}", e); 44 | Err(format!("JSON 解析失敗: {}", e)) 45 | } 46 | }, 47 | Ok(None) => Ok(None), 48 | Err(e) => { 49 | error!("❌ 讀取 Sled 設定失敗: {}", e); 50 | Err(format!("載入失敗: {}", e)) 51 | } 52 | } 53 | } 54 | 55 | /// 移除某個 key 56 | pub fn remove_config_sled(key: &str) { 57 | let db = get_sled_db(); 58 | if let Err(e) = db.remove(key.as_bytes()) { 59 | warn!("⚠️ 從 sled 移除緩存時發生錯誤: {}", e); 60 | } 61 | db.flush().ok(); 62 | } 63 | 64 | // 從緩存或 YAML 取得設定 65 | pub async fn get_cached_config() -> Arc { 66 | let cache_key = "models.yaml"; 67 | // 嘗試 sled 讀取(緩存優先,失敗再 yaml) 68 | match load_config_sled(cache_key) { 69 | Ok(Some(arc_cfg)) => { 70 | debug!("✅ Sled 緩存命中: {}", cache_key); 71 | arc_cfg 72 | } 73 | Ok(None) | Err(_) => { 74 | debug!("💾 sled 中無設定,從 YAML 讀取..."); 75 | match load_config_from_yaml() { 76 | Ok(conf) => { 77 | let _ = save_config_sled(cache_key, &conf); 78 | Arc::new(conf) 79 | } 80 | Err(e) => { 81 | warn!("⚠️ 無法從 YAML 載入設定,回退預設: {}", e); 82 | Arc::new(Config { 83 | enable: Some(false), 84 | models: std::collections::HashMap::new(), 85 | custom_models: None, 86 | }) 87 | } 88 | } 89 | } 90 | } 91 | } 92 | 93 | // 獲取URL緩存的TTL 94 | pub fn get_url_cache_ttl() -> Duration { 95 | let ttl_seconds = std::env::var("URL_CACHE_TTL_SECONDS") 96 | .ok() 97 | .and_then(|s| s.parse::().ok()) 98 | .unwrap_or(3 * 24 * 60 * 60); // 默認3天 99 | Duration::from_secs(ttl_seconds) 100 | } 101 | 102 | // 獲取URL緩存最大容量(MB) 103 | pub fn get_url_cache_size_mb() -> usize { 104 | std::env::var("URL_CACHE_SIZE_MB") 105 | .ok() 106 | .and_then(|s| s.parse::().ok()) 107 | .unwrap_or(100) // 默認100MB 108 | } 109 | 110 | // 存儲URL在緩存中,帶有過期時間 111 | pub fn cache_url(original_url: &str, poe_url: &str, size_bytes: usize) { 112 | let db = get_sled_db(); 113 | let tree_name = "urls"; 114 | let ttl = get_url_cache_ttl(); 115 | let key = format!("url:{}", original_url); 116 | // 當前時間 + TTL 117 | let expires_at = SystemTime::now() 118 | .checked_add(ttl) 119 | .unwrap_or_else(|| SystemTime::now() + ttl); 120 | // 轉換為時間戳 121 | let expires_secs = expires_at 122 | .duration_since(UNIX_EPOCH) 123 | .unwrap_or_else(|_| Duration::from_secs(0)) 124 | .as_secs(); 125 | // 儲存數據,使用格式 "過期時間戳:poe_url:大小" 126 | // 確保URL中的冒號不會干擾解析 127 | let store_value = format!("{}:{}:{}", expires_secs, poe_url, size_bytes); 128 | if let Ok(tree) = db.open_tree(tree_name) { 129 | match tree.insert(key.as_bytes(), store_value.as_bytes()) { 130 | Ok(_) => { 131 | debug!("✅ URL緩存已更新: {}", original_url); 132 | } 133 | Err(e) => { 134 | error!("❌ 保存URL緩存失敗: {}", e); 135 | } 136 | } 137 | } else { 138 | error!("❌ 無法開啟URL緩存樹"); 139 | } 140 | // 維護緩存大小 141 | check_and_control_cache_size(); 142 | } 143 | 144 | // 獲取緩存的URL 145 | pub fn get_cached_url(original_url: &str) -> Option<(String, usize)> { 146 | let db = get_sled_db(); 147 | let tree_name = "urls"; 148 | let key = format!("url:{}", original_url); 149 | let result = match db.open_tree(tree_name) { 150 | Ok(tree) => tree.get(key.as_bytes()), 151 | Err(e) => { 152 | error!("❌ 無法開啟URL緩存樹: {}", e); 153 | return None; 154 | } 155 | }; 156 | match result { 157 | Ok(Some(value_bytes)) => { 158 | if let Ok(value_str) = String::from_utf8(value_bytes.to_vec()) { 159 | let parts: Vec<&str> = value_str.split(':').collect(); 160 | if parts.len() >= 3 { 161 | // 正確解析格式: "expires_at:poe_url:size" 162 | if let Ok(expires_secs) = parts[0].parse::() { 163 | let now_secs = SystemTime::now() 164 | .duration_since(UNIX_EPOCH) 165 | .unwrap_or_else(|_| Duration::from_secs(0)) 166 | .as_secs(); 167 | // 檢查是否過期 168 | if expires_secs > now_secs { 169 | // 一個重要的修復:URL中可能含有冒號,需要正確處理 170 | // 取第一個部分為過期時間,最後一個部分為大小,中間的都是URL 171 | let size_str = parts.last().unwrap(); 172 | let poe_url = parts[1..(parts.len() - 1)].join(":"); 173 | if let Ok(size) = size_str.parse::() { 174 | // 更新過期時間(延長TTL) 175 | refresh_url_cache_ttl(original_url, &poe_url, size); 176 | debug!("✅ URL緩存命中並續期: {}", original_url); 177 | return Some((poe_url, size)); 178 | } 179 | } else { 180 | // 已過期,刪除項目 181 | if let Ok(tree) = db.open_tree(tree_name) { 182 | let _ = tree.remove(key.as_bytes()); 183 | debug!("🗑️ 刪除過期URL緩存: {}", original_url); 184 | } 185 | } 186 | } 187 | } 188 | } else { 189 | error!("❌ 無效的URL緩存值格式"); 190 | } 191 | None 192 | } 193 | Ok(None) => None, 194 | Err(e) => { 195 | error!("❌ 讀取URL緩存失敗: {}", e); 196 | None 197 | } 198 | } 199 | } 200 | 201 | // 刷新URL緩存的TTL 202 | fn refresh_url_cache_ttl(original_url: &str, poe_url: &str, size_bytes: usize) { 203 | cache_url(original_url, poe_url, size_bytes); 204 | } 205 | 206 | // 保存base64哈希到緩存 207 | pub fn cache_base64(hash: &str, poe_url: &str, size_bytes: usize) { 208 | let db = get_sled_db(); 209 | let tree_name = "base64"; 210 | let ttl = get_url_cache_ttl(); 211 | let key = format!("base64:{}", hash); 212 | let hash_prefix = if hash.len() > 8 { &hash[..8] } else { hash }; 213 | // 當前時間 + TTL 214 | let expires_at = SystemTime::now() 215 | .checked_add(ttl) 216 | .unwrap_or_else(|| SystemTime::now() + ttl); 217 | // 轉換為時間戳 218 | let expires_secs = expires_at 219 | .duration_since(UNIX_EPOCH) 220 | .unwrap_or_else(|_| Duration::from_secs(0)) 221 | .as_secs(); 222 | // 儲存數據,格式為 "expires_secs:poe_url:size_bytes" 223 | let store_value = format!("{}:{}:{}", expires_secs, poe_url, size_bytes); 224 | debug!( 225 | "💾 儲存base64緩存 | 哈希: {}... | 大小: {}bytes", 226 | hash_prefix, size_bytes 227 | ); 228 | match db.open_tree(tree_name) { 229 | Ok(tree) => match tree.insert(key.as_bytes(), store_value.as_bytes()) { 230 | Ok(_) => { 231 | debug!("✅ base64緩存已更新 | 哈希: {}...", hash_prefix); 232 | } 233 | Err(e) => { 234 | error!("❌ 保存base64緩存失敗: {} | 哈希: {}...", e, hash_prefix); 235 | } 236 | }, 237 | Err(e) => { 238 | error!("❌ 無法開啟base64緩存樹: {} | 哈希: {}...", e, hash_prefix); 239 | } 240 | } 241 | } 242 | 243 | // 從緩存獲取base64哈希對應的URL 244 | pub fn get_cached_base64(hash: &str) -> Option<(String, usize)> { 245 | let hash_prefix = if hash.len() > 8 { &hash[..8] } else { hash }; 246 | debug!("🔍 查詢base64緩存 | 哈希: {}...", hash_prefix); 247 | let db = get_sled_db(); 248 | let tree_name = "base64"; 249 | let key = format!("base64:{}", hash); 250 | let result = match db.open_tree(tree_name) { 251 | Ok(tree) => tree.get(key.as_bytes()), 252 | Err(e) => { 253 | error!("❌ 無法開啟base64緩存樹: {}", e); 254 | return None; 255 | } 256 | }; 257 | match result { 258 | Ok(Some(value_bytes)) => { 259 | if let Ok(value_str) = String::from_utf8(value_bytes.to_vec()) { 260 | let parts: Vec<&str> = value_str.split(':').collect(); 261 | if parts.len() >= 3 { 262 | if let Ok(expires_secs) = parts[0].parse::() { 263 | let now_secs = SystemTime::now() 264 | .duration_since(UNIX_EPOCH) 265 | .unwrap_or_else(|_| Duration::from_secs(0)) 266 | .as_secs(); 267 | // 檢查是否過期 268 | if expires_secs > now_secs { 269 | // 一個重要的修復:URL中可能含有冒號,需要正確處理 270 | let size_str = parts.last().unwrap(); 271 | let poe_url = parts[1..(parts.len() - 1)].join(":"); 272 | if let Ok(size) = size_str.parse::() { 273 | // 更新過期時間(延長TTL) 274 | refresh_base64_cache_ttl(hash, &poe_url, size); 275 | debug!("✅ base64緩存命中並續期 | 哈希: {}...", hash_prefix); 276 | return Some((poe_url, size)); 277 | } else { 278 | error!("❌ base64緩存大小無效: {}", size_str); 279 | } 280 | } else { 281 | // 已過期,刪除項目 282 | if let Ok(tree) = db.open_tree(tree_name) { 283 | let _ = tree.remove(key.as_bytes()); 284 | debug!("🗑️ 刪除過期base64緩存 | 哈希: {}...", hash_prefix); 285 | } 286 | } 287 | } else { 288 | error!("❌ base64緩存時間戳無效: {}", parts[0]); 289 | } 290 | } else { 291 | error!( 292 | "❌ base64緩存格式錯誤: {} (部分數: {})", 293 | value_str, 294 | parts.len() 295 | ); 296 | } 297 | } else { 298 | error!("❌ base64緩存值無法解析為字符串"); 299 | } 300 | None 301 | } 302 | Ok(None) => None, 303 | Err(e) => { 304 | error!("❌ 讀取base64緩存失敗: {} | 哈希: {}...", e, hash_prefix); 305 | None 306 | } 307 | } 308 | } 309 | 310 | // 刷新base64緩存的TTL 311 | fn refresh_base64_cache_ttl(hash: &str, poe_url: &str, size_bytes: usize) { 312 | cache_base64(hash, poe_url, size_bytes); 313 | } 314 | 315 | // 估算base64數據大小 316 | pub fn estimate_base64_size(data_url: &str) -> usize { 317 | if let Some(base64_part) = data_url.split(";base64,").nth(1) { 318 | return (base64_part.len() as f64 * 0.75) as usize; 319 | } 320 | 0 321 | } 322 | 323 | // 檢查並控制緩存大小 324 | fn check_and_control_cache_size() { 325 | let db = get_sled_db(); 326 | let max_size_mb = get_url_cache_size_mb(); 327 | let max_size_bytes = max_size_mb * 1024 * 1024; 328 | // 計算當前緩存總大小 329 | let mut current_size = 0; 330 | let mut entries = Vec::new(); 331 | 332 | // 收集url樹的項目 333 | if let Ok(tree) = db.open_tree("urls") { 334 | for (key, value) in tree.iter().flatten() { 335 | if let Ok(value_str) = String::from_utf8(value.to_vec()) { 336 | let parts: Vec<&str> = value_str.split(':').collect(); 337 | if parts.len() >= 3 { 338 | if let Ok(expires_secs) = parts[0].parse::() { 339 | if let Ok(size) = parts.last().unwrap().parse::() { 340 | current_size += size; 341 | entries.push((expires_secs, "urls".to_string(), key.to_vec(), size)); 342 | } 343 | } 344 | } 345 | } 346 | } 347 | } 348 | 349 | // 收集base64樹的項目 350 | if let Ok(tree) = db.open_tree("base64") { 351 | for (key, value) in tree.iter().flatten() { 352 | if let Ok(value_str) = String::from_utf8(value.to_vec()) { 353 | let parts: Vec<&str> = value_str.split(':').collect(); 354 | if parts.len() >= 3 { 355 | if let Ok(expires_secs) = parts[0].parse::() { 356 | if let Ok(size) = parts.last().unwrap().parse::() { 357 | current_size += size; 358 | entries.push((expires_secs, "base64".to_string(), key.to_vec(), size)); 359 | } 360 | } 361 | } 362 | } 363 | } 364 | } 365 | 366 | // 如果超過最大大小,清理空間 367 | if current_size > max_size_bytes { 368 | let excess_bytes = current_size - max_size_bytes; 369 | let mut bytes_to_free = excess_bytes + (max_size_bytes / 10); // 多釋放10%空間 370 | info!( 371 | "⚠️ 緩存大小 ({:.2}MB) 超出限制 ({:.2}MB),需釋放 {:.2}MB", 372 | current_size as f64 / 1024.0 / 1024.0, 373 | max_size_bytes as f64 / 1024.0 / 1024.0, 374 | bytes_to_free as f64 / 1024.0 / 1024.0 375 | ); 376 | 377 | // 按過期時間排序(最早過期的先刪除) 378 | entries.sort_by_key(|(expires, _, _, _)| *expires); 379 | let mut deleted = 0; 380 | 381 | for (_, tree_name, key, size) in entries { 382 | if bytes_to_free == 0 { 383 | break; 384 | } 385 | if let Ok(tree) = db.open_tree(&tree_name) { 386 | if let Err(e) = tree.remove(&key) { 387 | error!("❌ 刪除緩存項失敗: {}", e); 388 | } else { 389 | bytes_to_free = bytes_to_free.saturating_sub(size); 390 | deleted += 1; 391 | } 392 | } 393 | } 394 | 395 | if deleted > 0 { 396 | info!("🗑️ 已釋放 {} 個緩存項", deleted); 397 | } 398 | } 399 | } 400 | -------------------------------------------------------------------------------- /src/evert.rs: -------------------------------------------------------------------------------- 1 | use crate::types::*; 2 | use crate::utils::{convert_poe_error_to_openai, format_bytes_length}; 3 | use poe_api_process::{ChatEventType, ChatResponse, ChatResponseData}; 4 | use salvo::prelude::*; 5 | use std::collections::HashMap; 6 | use tracing::{debug, error}; 7 | 8 | // 事件積累上下文,用於收集處理事件期間的狀態 9 | #[derive(Debug, Clone, Default)] 10 | pub struct EventContext { 11 | pub content: String, 12 | pub replace_buffer: Option, 13 | pub file_refs: HashMap, 14 | pub tool_calls: Vec, 15 | is_replace_mode: bool, 16 | pub error: Option<(StatusCode, OpenAIErrorResponse)>, 17 | pub done: bool, 18 | pub completion_tokens: u32, 19 | first_text_processed: bool, 20 | pub role_chunk_sent: bool, 21 | has_new_file_refs: bool, 22 | pub image_urls_sent: bool, 23 | } 24 | 25 | // 事件處理器 trait 26 | trait EventHandler { 27 | fn handle(&self, event: &ChatResponse, ctx: &mut EventContext) -> Option; 28 | } 29 | 30 | // Text 事件處理器 31 | #[derive(Clone)] 32 | struct TextEventHandler; 33 | impl EventHandler for TextEventHandler { 34 | fn handle(&self, event: &ChatResponse, ctx: &mut EventContext) -> Option { 35 | if let Some(ChatResponseData::Text { text }) = &event.data { 36 | debug!( 37 | "📝 處理文本事件 | 長度: {} | is_replace_mode: {} | first_text_processed: {}", 38 | format_bytes_length(text.len()), 39 | ctx.is_replace_mode, 40 | ctx.first_text_processed 41 | ); 42 | 43 | // 如果是替換模式且第一個文本未處理,需要合併替換緩衝區與新文本 44 | if ctx.is_replace_mode && !ctx.first_text_processed { 45 | debug!("📝 合併第一個 Text 事件與 ReplaceResponse"); 46 | if let Some(replace_content) = &mut ctx.replace_buffer { 47 | replace_content.push_str(text); 48 | ctx.first_text_processed = true; 49 | // 返回合併後的內容以發送合併片段 50 | return Some(replace_content.clone()); 51 | } else { 52 | // 沒有 replace_buffer,直接添加到 content 53 | ctx.content.push_str(text); 54 | return Some(text.clone()); 55 | } 56 | } 57 | // 如果是替換模式且第一個文本已處理,則重置為非替換模式 58 | else if ctx.is_replace_mode && ctx.first_text_processed { 59 | debug!("🔄 重置替換模式,轉為直接文本模式"); 60 | ctx.is_replace_mode = false; 61 | ctx.first_text_processed = false; 62 | 63 | // 將 replace_buffer 的內容移至 content 64 | if let Some(replace_content) = ctx.replace_buffer.take() { 65 | ctx.content = replace_content; 66 | } 67 | // 直接將新文本添加到 content 68 | ctx.content.push_str(text); 69 | return Some(text.clone()); 70 | } else { 71 | // 非 replace 模式,直接累積並返回文本 72 | ctx.content.push_str(text); 73 | return Some(text.clone()); 74 | } 75 | } 76 | None 77 | } 78 | } 79 | 80 | // File 事件處理器 81 | #[derive(Clone)] 82 | struct FileEventHandler; 83 | impl EventHandler for FileEventHandler { 84 | fn handle(&self, event: &ChatResponse, ctx: &mut EventContext) -> Option { 85 | if let Some(ChatResponseData::File(file_data)) = &event.data { 86 | debug!( 87 | "🖼️ 處理檔案事件 | 名稱: {} | URL: {}", 88 | file_data.name, file_data.url 89 | ); 90 | ctx.file_refs 91 | .insert(file_data.inline_ref.clone(), file_data.clone()); 92 | ctx.has_new_file_refs = true; 93 | 94 | // 如果此時有 replace_buffer,處理它並發送 95 | if !ctx.image_urls_sent && ctx.replace_buffer.is_some() { 96 | // 只處理未發送過的 97 | let content = ctx.replace_buffer.as_ref().unwrap(); 98 | if content.contains(&format!("[{}]", file_data.inline_ref)) { 99 | debug!( 100 | "🖼️ 檢測到 ReplaceResponse 包含圖片引用 [{}],立即處理", 101 | file_data.inline_ref 102 | ); 103 | // 處理這個文本中的圖片引用 104 | let mut processed = content.clone(); 105 | let img_marker = format!("[{}]", file_data.inline_ref); 106 | let replacement = format!("({})", file_data.url); 107 | processed = processed.replace(&img_marker, &replacement); 108 | ctx.image_urls_sent = true; // 標記已發送 109 | return Some(processed); 110 | } 111 | } 112 | } 113 | None 114 | } 115 | } 116 | 117 | // ReplaceResponse 事件處理器 118 | #[derive(Clone)] 119 | struct ReplaceResponseEventHandler; 120 | impl EventHandler for ReplaceResponseEventHandler { 121 | fn handle(&self, event: &ChatResponse, ctx: &mut EventContext) -> Option { 122 | if let Some(ChatResponseData::Text { text }) = &event.data { 123 | debug!( 124 | "🔄 處理 ReplaceResponse 事件 | 長度: {}", 125 | format_bytes_length(text.len()) 126 | ); 127 | ctx.is_replace_mode = true; 128 | ctx.replace_buffer = Some(text.clone()); 129 | ctx.first_text_processed = false; 130 | 131 | // 檢查是否有文件引用需要處理 132 | if !ctx.file_refs.is_empty() && text.contains('[') { 133 | debug!("🔄 ReplaceResponse 可能包含圖片引用,檢查並處理"); 134 | // 處理這個文本中的圖片引用 135 | let mut processed = text.clone(); 136 | let mut has_refs = false; 137 | 138 | for (ref_id, file_data) in &ctx.file_refs { 139 | let img_marker = format!("[{}]", ref_id); 140 | if processed.contains(&img_marker) { 141 | let replacement = format!("({})", file_data.url); 142 | processed = processed.replace(&img_marker, &replacement); 143 | has_refs = true; 144 | debug!("🖼️ 替換圖片引用 | ID: {} | URL: {}", ref_id, file_data.url); 145 | } 146 | } 147 | 148 | if has_refs { 149 | // 如果確實包含了圖片引用,立即返回處理後的內容 150 | debug!("✅ ReplaceResponse 含有圖片引用,立即發送處理後內容"); 151 | ctx.image_urls_sent = true; // 標記已發送 152 | return Some(processed); 153 | } 154 | } 155 | 156 | // 推遲 ReplaceResponse 的輸出,等待後續 Text 事件 157 | debug!("🔄 推遲 ReplaceResponse 的輸出,等待後續 Text 事件"); 158 | } 159 | None // 不直接發送,等待與 Text 合併 160 | } 161 | } 162 | 163 | // Json 事件處理器 (用於 Tool Calls) 164 | #[derive(Clone)] 165 | struct JsonEventHandler; 166 | impl EventHandler for JsonEventHandler { 167 | fn handle(&self, event: &ChatResponse, ctx: &mut EventContext) -> Option { 168 | debug!("📝 處理 JSON 事件"); 169 | if let Some(ChatResponseData::ToolCalls(tool_calls)) = &event.data { 170 | debug!("🔧 處理工具調用,數量: {}", tool_calls.len()); 171 | ctx.tool_calls.extend(tool_calls.clone()); 172 | // 返回 Some,表示需要發送工具調用 173 | return Some("tool_calls".to_string()); 174 | } 175 | None 176 | } 177 | } 178 | 179 | // Error 事件處理器 180 | #[derive(Clone)] 181 | struct ErrorEventHandler; 182 | impl EventHandler for ErrorEventHandler { 183 | fn handle(&self, event: &ChatResponse, ctx: &mut EventContext) -> Option { 184 | if let Some(ChatResponseData::Error { text, allow_retry }) = &event.data { 185 | error!("❌ 處理錯誤事件: {}", text); 186 | let (status, error_response) = convert_poe_error_to_openai(text, *allow_retry); 187 | ctx.error = Some((status, error_response)); 188 | return Some("error".to_string()); 189 | } 190 | None 191 | } 192 | } 193 | 194 | // Done 事件處理器 195 | #[derive(Clone)] 196 | struct DoneEventHandler; 197 | impl EventHandler for DoneEventHandler { 198 | fn handle(&self, _event: &ChatResponse, ctx: &mut EventContext) -> Option { 199 | debug!("✅ 處理 Done 事件"); 200 | ctx.done = true; 201 | 202 | // 只有當未發送過圖片URL時才處理 203 | if !ctx.image_urls_sent && ctx.replace_buffer.is_some() && !ctx.file_refs.is_empty() { 204 | let content = ctx.replace_buffer.as_ref().unwrap(); 205 | debug!("🔍 檢查完成事件時是否有未處理的圖片引用"); 206 | let mut processed = content.clone(); 207 | let mut has_refs = false; 208 | 209 | for (ref_id, file_data) in &ctx.file_refs { 210 | let img_marker = format!("[{}]", ref_id); 211 | if processed.contains(&img_marker) { 212 | let replacement = format!("({})", file_data.url); 213 | processed = processed.replace(&img_marker, &replacement); 214 | has_refs = true; 215 | debug!( 216 | "🖼️ 完成前替換圖片引用 | ID: {} | URL: {}", 217 | ref_id, file_data.url 218 | ); 219 | } 220 | } 221 | 222 | if has_refs { 223 | debug!("✅ 完成前處理了圖片引用"); 224 | ctx.image_urls_sent = true; // 標記已發送 225 | return Some(processed); 226 | } 227 | } 228 | 229 | Some("done".to_string()) 230 | } 231 | } 232 | 233 | // 事件處理器管理器 234 | #[derive(Clone)] 235 | pub struct EventHandlerManager { 236 | text_handler: TextEventHandler, 237 | file_handler: FileEventHandler, 238 | replace_handler: ReplaceResponseEventHandler, 239 | json_handler: JsonEventHandler, 240 | error_handler: ErrorEventHandler, 241 | done_handler: DoneEventHandler, 242 | } 243 | 244 | impl EventHandlerManager { 245 | pub fn new() -> Self { 246 | Self { 247 | text_handler: TextEventHandler, 248 | file_handler: FileEventHandler, 249 | replace_handler: ReplaceResponseEventHandler, 250 | json_handler: JsonEventHandler, 251 | error_handler: ErrorEventHandler, 252 | done_handler: DoneEventHandler, 253 | } 254 | } 255 | 256 | pub fn handle(&self, event: &ChatResponse, ctx: &mut EventContext) -> Option { 257 | match event.event { 258 | ChatEventType::Text => self.text_handler.handle(event, ctx), 259 | ChatEventType::File => self.file_handler.handle(event, ctx), 260 | ChatEventType::ReplaceResponse => self.replace_handler.handle(event, ctx), 261 | ChatEventType::Json => self.json_handler.handle(event, ctx), 262 | ChatEventType::Error => self.error_handler.handle(event, ctx), 263 | ChatEventType::Done => self.done_handler.handle(event, ctx), 264 | } 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/handlers/admin.rs: -------------------------------------------------------------------------------- 1 | use crate::cache::{remove_config_sled, save_config_sled}; 2 | use crate::types::Config; 3 | use crate::utils::get_config_path; 4 | use askama::Template; 5 | use salvo::basic_auth::{BasicAuth, BasicAuthValidator}; 6 | use salvo::prelude::*; 7 | use serde_json::json; 8 | use std::fs; 9 | use tracing::info; 10 | 11 | #[derive(Template)] 12 | #[template(path = "admin.html")] 13 | struct AdminTemplate; 14 | 15 | #[handler] 16 | async fn admin_page(res: &mut Response) { 17 | let template = AdminTemplate; 18 | res.render(Text::Html(template.render().unwrap())); 19 | } 20 | 21 | #[handler] 22 | async fn get_config(res: &mut Response) { 23 | invalidate_config_cache(); 24 | let config = load_config().unwrap_or_default(); 25 | res.render(Json(config)); 26 | } 27 | 28 | #[handler] 29 | async fn save_config(req: &mut Request, res: &mut Response) { 30 | match req.parse_json::().await { 31 | Ok(config) => { 32 | if let Err(e) = save_config_to_file(&config) { 33 | res.status_code(StatusCode::INTERNAL_SERVER_ERROR); 34 | res.render(Json(json!({ "error": e.to_string() }))); 35 | } else { 36 | info!("✅ models.yaml 已成功儲存。"); 37 | // 同步寫入 sled 快取 38 | let _ = save_config_sled("models.yaml", &config); 39 | invalidate_config_cache(); 40 | res.render(Json(json!({ "status": "success" }))); 41 | } 42 | } 43 | Err(e) => { 44 | res.status_code(StatusCode::BAD_REQUEST); 45 | res.render(Json(json!({ "error": e.to_string() }))); 46 | } 47 | } 48 | } 49 | 50 | fn load_config() -> Result> { 51 | let config_path = get_config_path("models.yaml"); 52 | if config_path.exists() { 53 | let contents = fs::read_to_string(config_path)?; 54 | match serde_yaml::from_str::(&contents) { 55 | Ok(mut config) => { 56 | // 確保 custom_models 字段存在 57 | if config.custom_models.is_none() { 58 | config.custom_models = Some(Vec::new()); 59 | } 60 | Ok(config) 61 | } 62 | Err(e) => Err(Box::new(e)), 63 | } 64 | } else { 65 | Ok(Config { 66 | enable: Some(false), 67 | models: std::collections::HashMap::new(), 68 | custom_models: Some(Vec::new()), // 初始化為空陣列而非 None 69 | }) 70 | } 71 | } 72 | 73 | fn save_config_to_file(config: &Config) -> Result<(), Box> { 74 | let yaml = serde_yaml::to_string(config)?; 75 | let config_path = get_config_path("models.yaml"); 76 | fs::write(config_path, yaml)?; 77 | Ok(()) 78 | } 79 | 80 | fn invalidate_config_cache() { 81 | info!("🗑️ 清除 models.yaml 設定緩存..."); 82 | remove_config_sled("models.yaml"); 83 | } 84 | 85 | pub struct AdminAuthValidator; 86 | 87 | impl BasicAuthValidator for AdminAuthValidator { 88 | async fn validate(&self, username: &str, password: &str, _depot: &mut Depot) -> bool { 89 | let valid_username = 90 | std::env::var("ADMIN_USERNAME").unwrap_or_else(|_| "admin".to_string()); 91 | let valid_password = 92 | std::env::var("ADMIN_PASSWORD").unwrap_or_else(|_| "123456".to_string()); 93 | username == valid_username && password == valid_password 94 | } 95 | } 96 | 97 | pub fn admin_routes() -> Router { 98 | let auth_handler = BasicAuth::new(AdminAuthValidator); 99 | Router::new() 100 | .hoop(auth_handler) // 加入認證中間件 101 | .push(Router::with_path("admin").get(admin_page)) 102 | .push( 103 | Router::with_path("api/admin/config") 104 | .get(get_config) 105 | .post(save_config), 106 | ) 107 | } 108 | -------------------------------------------------------------------------------- /src/handlers/chat.rs: -------------------------------------------------------------------------------- 1 | use crate::cache::get_cached_config; 2 | use crate::evert::{EventContext, EventHandlerManager}; 3 | use crate::poe_client::{PoeClientWrapper, create_chat_request}; 4 | use crate::types::*; 5 | use crate::utils::{ 6 | convert_poe_error_to_openai, count_completion_tokens, count_message_tokens, 7 | format_bytes_length, format_duration, process_message_images, 8 | }; 9 | use chrono::Utc; 10 | use futures_util::future::{self}; 11 | use futures_util::stream::{self, Stream, StreamExt}; 12 | use nanoid::nanoid; 13 | use poe_api_process::{ChatEventType, ChatResponse, PoeError}; 14 | use salvo::http::header; 15 | use salvo::prelude::*; 16 | use serde_json::json; 17 | use std::collections::HashMap; 18 | use std::pin::Pin; 19 | use std::sync::{Arc, Mutex}; 20 | use std::time::Instant; 21 | use tracing::{debug, error, info, warn}; 22 | 23 | #[handler] 24 | pub async fn chat_completions(req: &mut Request, res: &mut Response) { 25 | let start_time = Instant::now(); 26 | info!("📝 收到新的聊天完成請求"); 27 | 28 | let max_size: usize = std::env::var("MAX_REQUEST_SIZE") 29 | .unwrap_or_else(|_| "1073741824".to_string()) 30 | .parse() 31 | .unwrap_or(1024 * 1024 * 1024); 32 | 33 | // 從緩存獲取 models.yaml 配置 34 | let config = get_cached_config().await; 35 | debug!("🔧 從緩存獲取配置 | 啟用狀態: {:?}", config.enable); 36 | 37 | // 驗證授權 38 | let access_key = match req.headers().get("Authorization") { 39 | Some(auth) => { 40 | let auth_str = auth.to_str().unwrap_or(""); 41 | if let Some(stripped) = auth_str.strip_prefix("Bearer ") { 42 | debug!("🔑 驗證令牌長度: {}", stripped.len()); 43 | stripped.to_string() 44 | } else { 45 | error!("❌ 無效的授權格式"); 46 | res.status_code(StatusCode::UNAUTHORIZED); 47 | res.render(Json(json!({ "error": "無效的 Authorization" }))); 48 | return; 49 | } 50 | } 51 | None => { 52 | error!("❌ 缺少授權標頭"); 53 | res.status_code(StatusCode::UNAUTHORIZED); 54 | res.render(Json(json!({ "error": "缺少 Authorization" }))); 55 | return; 56 | } 57 | }; 58 | 59 | // 解析請求體 60 | let chat_request = match req.payload_with_max_size(max_size).await { 61 | Ok(bytes) => match serde_json::from_slice::(bytes) { 62 | Ok(req) => { 63 | debug!( 64 | "📊 請求解析成功 | 模型: {} | 訊息數量: {} | 是否串流: {:?}", 65 | req.model, 66 | req.messages.len(), 67 | req.stream 68 | ); 69 | req 70 | } 71 | Err(e) => { 72 | error!("❌ JSON 解析失敗: {}", e); 73 | res.status_code(StatusCode::BAD_REQUEST); 74 | res.render(Json(OpenAIErrorResponse { 75 | error: OpenAIError { 76 | message: format!("JSON 解析失敗: {}", e), 77 | r#type: "invalid_request_error".to_string(), 78 | code: "parse_error".to_string(), 79 | param: None, 80 | }, 81 | })); 82 | return; 83 | } 84 | }, 85 | Err(e) => { 86 | error!("❌ 請求大小超過限制或讀取失敗: {}", e); 87 | res.status_code(StatusCode::PAYLOAD_TOO_LARGE); 88 | res.render(Json(OpenAIErrorResponse { 89 | error: OpenAIError { 90 | message: format!("請求大小超過限制 ({} bytes) 或讀取失敗: {}", max_size, e), 91 | r#type: "invalid_request_error".to_string(), 92 | code: "payload_too_large".to_string(), 93 | param: None, 94 | }, 95 | })); 96 | return; 97 | } 98 | }; 99 | 100 | // 尋找映射的原始模型名稱 101 | let (display_model, original_model) = if config.enable.unwrap_or(false) { 102 | let requested_model = chat_request.model.clone(); 103 | // 檢查當前請求的模型是否是某個映射的目標 104 | let mapping_entry = config.models.iter().find(|(_, cfg)| { 105 | if let Some(mapping) = &cfg.mapping { 106 | mapping.to_lowercase() == requested_model.to_lowercase() 107 | } else { 108 | false 109 | } 110 | }); 111 | if let Some((original_name, _)) = mapping_entry { 112 | // 如果找到映射,使用原始模型名稱 113 | debug!("🔄 反向模型映射: {} -> {}", requested_model, original_name); 114 | (requested_model, original_name.clone()) 115 | } else { 116 | // 如果沒找到映射,檢查是否有直接映射配置 117 | if let Some(model_config) = config.models.get(&requested_model) { 118 | if let Some(mapped_name) = &model_config.mapping { 119 | debug!("🔄 直接模型映射: {} -> {}", requested_model, mapped_name); 120 | (requested_model.clone(), requested_model) 121 | } else { 122 | // 沒有映射配置,使用原始名稱 123 | (requested_model.clone(), requested_model) 124 | } 125 | } else { 126 | // 完全沒有相關配置,使用原始名稱 127 | (requested_model.clone(), requested_model) 128 | } 129 | } 130 | } else { 131 | // 配置未啟用,直接使用原始名稱 132 | (chat_request.model.clone(), chat_request.model.clone()) 133 | }; 134 | info!("🤖 使用模型: {} (原始: {})", display_model, original_model); 135 | 136 | // 創建客戶端 137 | let client = PoeClientWrapper::new(&original_model, &access_key); 138 | 139 | // 處理消息中的image_url 140 | let mut messages = chat_request.messages.clone(); 141 | if let Err(e) = process_message_images(&client, &mut messages).await { 142 | error!("❌ 處理文件上傳失敗: {}", e); 143 | res.status_code(StatusCode::INTERNAL_SERVER_ERROR); 144 | res.render(Json(OpenAIErrorResponse { 145 | error: OpenAIError { 146 | message: format!("處理文件上傳失敗: {}", e), 147 | r#type: "processing_error".to_string(), 148 | code: "file_processing_failed".to_string(), 149 | param: None, 150 | }, 151 | })); 152 | return; 153 | } 154 | 155 | // 計算 prompt_tokens 156 | let prompt_tokens = count_message_tokens(&messages); 157 | debug!("📊 計算 prompt_tokens: {}", prompt_tokens); 158 | 159 | let stream = chat_request.stream.unwrap_or(false); 160 | debug!("🔄 請求模式: {}", if stream { "串流" } else { "非串流" }); 161 | 162 | // 創建 chat 請求 163 | let chat_request_obj = create_chat_request( 164 | &original_model, 165 | messages, 166 | chat_request.temperature, 167 | chat_request.tools, 168 | chat_request.logit_bias, 169 | chat_request.stop, 170 | ) 171 | .await; 172 | 173 | // 檢查是否需要包含 usage 統計 174 | let include_usage = chat_request 175 | .stream_options 176 | .as_ref() 177 | .and_then(|opts| opts.include_usage) 178 | .unwrap_or(false); 179 | debug!("📊 是否包含 usage 統計: {}", include_usage); 180 | 181 | // 創建輸出生成器 182 | let output_generator = 183 | OutputGenerator::new(display_model.clone(), prompt_tokens, include_usage); 184 | 185 | match client.stream_request(chat_request_obj).await { 186 | Ok(event_stream) => { 187 | if stream { 188 | // 處理串流響應 189 | handle_stream_response(res, event_stream, output_generator).await; 190 | } else { 191 | // 處理非串流響應 192 | handle_non_stream_response(res, event_stream, output_generator).await; 193 | } 194 | } 195 | Err(e) => { 196 | error!("❌ 建立串流請求失敗: {}", e); 197 | res.status_code(StatusCode::INTERNAL_SERVER_ERROR); 198 | res.render(Json(json!({ "error": e.to_string() }))); 199 | } 200 | } 201 | 202 | let duration = start_time.elapsed(); 203 | info!("✅ 請求處理完成 | 耗時: {}", format_duration(duration)); 204 | } 205 | 206 | // 處理串流響應 207 | async fn handle_stream_response( 208 | res: &mut Response, 209 | event_stream: Pin> + Send>>, 210 | output_generator: OutputGenerator, 211 | ) { 212 | let start_time = Instant::now(); 213 | let id = output_generator.id.clone(); 214 | let model = output_generator.model.clone(); 215 | let include_usage = output_generator.include_usage; 216 | info!( 217 | "🌊 開始處理串流響應 | ID: {} | 模型: {} | 包含使用統計: {}", 218 | id, model, include_usage 219 | ); 220 | 221 | // 設置串流響應的頭部 222 | res.headers_mut() 223 | .insert(header::CONTENT_TYPE, "text/event-stream".parse().unwrap()); 224 | res.headers_mut() 225 | .insert(header::CACHE_CONTROL, "no-cache".parse().unwrap()); 226 | res.headers_mut() 227 | .insert(header::CONNECTION, "keep-alive".parse().unwrap()); 228 | 229 | // 處理事件流並生成輸出 230 | let processed_stream = output_generator 231 | .process_stream(Box::pin(event_stream)) 232 | .await; 233 | res.stream(processed_stream); 234 | 235 | let duration = start_time.elapsed(); 236 | info!( 237 | "✅ 串流響應處理完成 | ID: {} | 耗時: {}", 238 | id, 239 | format_duration(duration) 240 | ); 241 | } 242 | 243 | // 處理非串流響應 244 | async fn handle_non_stream_response( 245 | res: &mut Response, 246 | mut event_stream: Pin> + Send>>, 247 | output_generator: OutputGenerator, 248 | ) { 249 | let start_time = Instant::now(); 250 | let id = output_generator.id.clone(); 251 | let model = output_generator.model.clone(); 252 | let include_usage = output_generator.include_usage; 253 | info!( 254 | "📦 開始處理非串流響應 | ID: {} | 模型: {} | 包含使用統計: {}", 255 | id, model, include_usage 256 | ); 257 | 258 | let handler_manager = EventHandlerManager::new(); 259 | let mut ctx = EventContext::default(); 260 | 261 | // 處理所有事件 262 | while let Some(result) = event_stream.next().await { 263 | match result { 264 | Ok(event) => { 265 | handler_manager.handle(&event, &mut ctx); 266 | // 檢查是否有錯誤 267 | if let Some((status, error_response)) = &ctx.error { 268 | error!("❌ 處理錯誤: {:?}", error_response); 269 | res.status_code(*status); 270 | res.render(Json(error_response)); 271 | return; 272 | } 273 | // 檢查是否完成 274 | if ctx.done { 275 | debug!("✅ 收到完成事件"); 276 | break; 277 | } 278 | } 279 | Err(e) => { 280 | error!("❌ 處理錯誤: {}", e); 281 | let (status, error_response) = convert_poe_error_to_openai(&e.to_string(), false); 282 | res.status_code(status); 283 | res.render(Json(error_response)); 284 | return; 285 | } 286 | } 287 | } 288 | 289 | // 創建最終響應 290 | let response = output_generator.create_final_response(&mut ctx); 291 | res.render(Json(response)); 292 | 293 | let duration = start_time.elapsed(); 294 | info!( 295 | "✅ 非串流響應處理完成 | ID: {} | 耗時: {}", 296 | id, 297 | format_duration(duration) 298 | ); 299 | } 300 | 301 | // 輸出生成器 - 用於將 EventContext 轉換為最終輸出 302 | #[derive(Clone)] 303 | struct OutputGenerator { 304 | id: String, 305 | created: i64, 306 | model: String, 307 | prompt_tokens: u32, 308 | include_usage: bool, 309 | } 310 | 311 | impl OutputGenerator { 312 | fn new(model: String, prompt_tokens: u32, include_usage: bool) -> Self { 313 | Self { 314 | id: nanoid!(10), 315 | created: Utc::now().timestamp(), 316 | model, 317 | prompt_tokens, 318 | include_usage, 319 | } 320 | } 321 | 322 | // 處理文件引用,將 [ref_id] 替換為 (url) 323 | fn process_file_references( 324 | &self, 325 | content: &str, 326 | file_refs: &HashMap, 327 | ) -> String { 328 | if file_refs.is_empty() { 329 | return content.to_string(); 330 | } 331 | let mut processed = content.to_string(); 332 | let mut has_replaced = false; 333 | 334 | for (ref_id, file_data) in file_refs { 335 | let img_marker = format!("[{}]", ref_id); 336 | if processed.contains(&img_marker) { 337 | let replacement = format!("({})", file_data.url); 338 | processed = processed.replace(&img_marker, &replacement); 339 | debug!("🖼️ 替換圖片引用 | ID: {} | URL: {}", ref_id, file_data.url); 340 | has_replaced = true; 341 | } 342 | } 343 | 344 | if has_replaced { 345 | debug!("✅ 成功替換圖片引用"); 346 | } else if processed.contains('[') && processed.contains(']') { 347 | warn!( 348 | "⚠️ 文本包含可能的圖片引用格式,但未找到對應引用: {}", 349 | processed 350 | ); 351 | } 352 | 353 | processed 354 | } 355 | 356 | // 計算 token 使用情況 357 | fn calculate_tokens(&self, ctx: &mut EventContext) -> (u32, u32, u32) { 358 | let content = match &ctx.replace_buffer { 359 | Some(replace_content) => replace_content, 360 | None => &ctx.content, 361 | }; 362 | let completion_tokens = count_completion_tokens(content); 363 | ctx.completion_tokens = completion_tokens; 364 | let total_tokens = self.prompt_tokens + completion_tokens; 365 | (self.prompt_tokens, completion_tokens, total_tokens) 366 | } 367 | 368 | // 創建角色 chunk 369 | fn create_role_chunk(&self) -> ChatCompletionChunk { 370 | let role_delta = Delta { 371 | role: Some("assistant".to_string()), 372 | content: None, 373 | refusal: None, 374 | tool_calls: None, 375 | }; 376 | ChatCompletionChunk { 377 | id: format!("chatcmpl-{}", self.id), 378 | object: "chat.completion.chunk".to_string(), 379 | created: self.created, 380 | model: self.model.clone(), 381 | choices: vec![Choice { 382 | index: 0, 383 | delta: role_delta, 384 | finish_reason: None, 385 | }], 386 | } 387 | } 388 | 389 | // 創建串流 chunk 390 | fn create_stream_chunk( 391 | &self, 392 | content: &str, 393 | finish_reason: Option, 394 | ) -> ChatCompletionChunk { 395 | let mut delta = Delta { 396 | role: None, 397 | content: None, 398 | refusal: None, 399 | tool_calls: None, 400 | }; 401 | delta.content = Some(content.to_string()); 402 | debug!( 403 | "🔧 創建串流片段 | ID: {} | 內容長度: {}", 404 | self.id, 405 | format_bytes_length(content.len()) 406 | ); 407 | ChatCompletionChunk { 408 | id: format!("chatcmpl-{}", self.id), 409 | object: "chat.completion.chunk".to_string(), 410 | created: self.created, 411 | model: self.model.clone(), 412 | choices: vec![Choice { 413 | index: 0, 414 | delta, 415 | finish_reason, 416 | }], 417 | } 418 | } 419 | 420 | // 創建工具調用 chunk 421 | fn create_tool_calls_chunk( 422 | &self, 423 | tool_calls: &[poe_api_process::types::ChatToolCall], 424 | ) -> ChatCompletionChunk { 425 | let tool_delta = Delta { 426 | role: None, 427 | content: None, 428 | refusal: None, 429 | tool_calls: Some(tool_calls.to_vec()), 430 | }; 431 | ChatCompletionChunk { 432 | id: format!("chatcmpl-{}", self.id), 433 | object: "chat.completion.chunk".to_string(), 434 | created: self.created, 435 | model: self.model.clone(), 436 | choices: vec![Choice { 437 | index: 0, 438 | delta: tool_delta, 439 | finish_reason: Some("tool_calls".to_string()), 440 | }], 441 | } 442 | } 443 | 444 | // 創建最終完整回應(非串流模式) 445 | fn create_final_response(&self, ctx: &mut EventContext) -> ChatCompletionResponse { 446 | // 處理內容,包括文件引用替換 447 | let content = if let Some(replace_content) = &ctx.replace_buffer { 448 | self.process_file_references(replace_content, &ctx.file_refs) 449 | } else { 450 | self.process_file_references(&ctx.content, &ctx.file_refs) 451 | }; 452 | // 計算 token 453 | let (prompt_tokens, completion_tokens, total_tokens) = self.calculate_tokens(ctx); 454 | // 確定 finish_reason 455 | let finish_reason = if !ctx.tool_calls.is_empty() { 456 | "tool_calls".to_string() 457 | } else { 458 | "stop".to_string() 459 | }; 460 | debug!( 461 | "📤 準備發送回應 | 內容長度: {} | 工具調用數量: {} | 完成原因: {}", 462 | format_bytes_length(content.len()), 463 | ctx.tool_calls.len(), 464 | finish_reason 465 | ); 466 | if self.include_usage { 467 | debug!( 468 | "📊 Token 使用統計 | prompt_tokens: {} | completion_tokens: {} | total_tokens: {}", 469 | prompt_tokens, completion_tokens, total_tokens 470 | ); 471 | } 472 | // 創建響應 473 | let mut response = ChatCompletionResponse { 474 | id: format!("chatcmpl-{}", self.id), 475 | object: "chat.completion".to_string(), 476 | created: self.created, 477 | model: self.model.clone(), 478 | choices: vec![CompletionChoice { 479 | index: 0, 480 | message: CompletionMessage { 481 | role: "assistant".to_string(), 482 | content, 483 | refusal: None, 484 | tool_calls: if ctx.tool_calls.is_empty() { 485 | None 486 | } else { 487 | Some(ctx.tool_calls.clone()) 488 | }, 489 | }, 490 | logprobs: None, 491 | finish_reason: Some(finish_reason), 492 | }], 493 | usage: None, 494 | }; 495 | if self.include_usage { 496 | response.usage = Some(serde_json::json!({ 497 | "prompt_tokens": prompt_tokens, 498 | "completion_tokens": completion_tokens, 499 | "total_tokens": total_tokens, 500 | "prompt_tokens_details": {"cached_tokens": 0} 501 | })); 502 | } 503 | response 504 | } 505 | 506 | // 直接處理串流事件並產生輸出,無需預讀 507 | pub async fn process_stream( 508 | self, 509 | event_stream: S, 510 | ) -> impl Stream> + Send + 'static 511 | where 512 | S: Stream> + Send + Unpin + 'static, 513 | { 514 | let ctx = Arc::new(Mutex::new(EventContext::default())); 515 | let handler_manager = EventHandlerManager::new(); 516 | 517 | // 直接用 unfold 邏輯處理事件流 518 | let stream_processor = stream::unfold( 519 | (event_stream, false, ctx, handler_manager, self), 520 | move |(mut event_stream, mut is_done, ctx_arc, handler_manager, generator)| { 521 | let ctx_arc_clone = Arc::clone(&ctx_arc); 522 | async move { 523 | if is_done { 524 | debug!("✅ 串流處理完成"); 525 | return None; 526 | } 527 | 528 | match event_stream.next().await { 529 | Some(Ok(event)) => { 530 | // 鎖定上下文並處理事件 531 | let mut output_content: Option = None; 532 | { 533 | let mut ctx_guard = ctx_arc_clone.lock().unwrap(); 534 | 535 | // 處理事件並獲取要發送的內容 536 | let chunk_content_opt = 537 | handler_manager.handle(&event, &mut ctx_guard); 538 | 539 | // 檢查錯誤 540 | if let Some((_, error_response)) = &ctx_guard.error { 541 | debug!("❌ 檢測到錯誤,中斷串流"); 542 | let error_json = serde_json::to_string(error_response).unwrap(); 543 | return Some(( 544 | Ok(format!("data: {}\n\n", error_json)), 545 | (event_stream, true, ctx_arc, handler_manager, generator), 546 | )); 547 | } 548 | 549 | // 檢查是否完成 550 | if ctx_guard.done { 551 | debug!("✅ 檢測到完成信號"); 552 | is_done = true; 553 | } 554 | 555 | // 處理返回的內容 556 | match event.event { 557 | ChatEventType::Text => { 558 | if let Some(chunk_content) = chunk_content_opt { 559 | debug!("📝 處理普通 Text 事件"); 560 | let processed = generator.process_file_references( 561 | &chunk_content, 562 | &ctx_guard.file_refs, 563 | ); 564 | 565 | // 判斷是否需要發送角色塊 566 | if !ctx_guard.role_chunk_sent { 567 | let role_chunk = generator.create_role_chunk(); 568 | let role_json = 569 | serde_json::to_string(&role_chunk).unwrap(); 570 | ctx_guard.role_chunk_sent = true; 571 | 572 | let content_chunk = 573 | generator.create_stream_chunk(&processed, None); 574 | let content_json = 575 | serde_json::to_string(&content_chunk).unwrap(); 576 | 577 | output_content = Some(format!( 578 | "data: {}\n\ndata: {}\n\n", 579 | role_json, content_json 580 | )); 581 | } else { 582 | let chunk = 583 | generator.create_stream_chunk(&processed, None); 584 | let json = serde_json::to_string(&chunk).unwrap(); 585 | output_content = 586 | Some(format!("data: {}\n\n", json)); 587 | } 588 | } 589 | } 590 | ChatEventType::File => { 591 | // 處理文件事件,如果返回了內容,表示有圖片引用需要立即處理 592 | if let Some(chunk_content) = chunk_content_opt { 593 | debug!("🖼️ 處理檔案引用,產生包含URL的輸出"); 594 | 595 | // 判斷是否需要發送角色塊 596 | if !ctx_guard.role_chunk_sent { 597 | let role_chunk = generator.create_role_chunk(); 598 | let role_json = 599 | serde_json::to_string(&role_chunk).unwrap(); 600 | ctx_guard.role_chunk_sent = true; 601 | 602 | let content_chunk = generator 603 | .create_stream_chunk(&chunk_content, None); 604 | let content_json = 605 | serde_json::to_string(&content_chunk).unwrap(); 606 | 607 | output_content = Some(format!( 608 | "data: {}\n\ndata: {}\n\n", 609 | role_json, content_json 610 | )); 611 | } else { 612 | let chunk = generator 613 | .create_stream_chunk(&chunk_content, None); 614 | let json = serde_json::to_string(&chunk).unwrap(); 615 | output_content = 616 | Some(format!("data: {}\n\n", json)); 617 | } 618 | } 619 | } 620 | ChatEventType::ReplaceResponse => { 621 | // 如果 ReplaceResponse 直接返回了內容,說明其中包含了圖片引用 622 | if let Some(chunk_content) = chunk_content_opt { 623 | debug!("🔄 ReplaceResponse 包含圖片引用,直接發送"); 624 | 625 | // 判斷是否需要發送角色塊 626 | if !ctx_guard.role_chunk_sent { 627 | let role_chunk = generator.create_role_chunk(); 628 | let role_json = 629 | serde_json::to_string(&role_chunk).unwrap(); 630 | ctx_guard.role_chunk_sent = true; 631 | 632 | let content_chunk = generator 633 | .create_stream_chunk(&chunk_content, None); 634 | let content_json = 635 | serde_json::to_string(&content_chunk).unwrap(); 636 | 637 | output_content = Some(format!( 638 | "data: {}\n\ndata: {}\n\n", 639 | role_json, content_json 640 | )); 641 | } else { 642 | let chunk = generator 643 | .create_stream_chunk(&chunk_content, None); 644 | let json = serde_json::to_string(&chunk).unwrap(); 645 | output_content = 646 | Some(format!("data: {}\n\n", json)); 647 | } 648 | } 649 | } 650 | ChatEventType::Json => { 651 | if !ctx_guard.tool_calls.is_empty() { 652 | debug!("🔧 處理工具調用"); 653 | let tool_chunk = generator 654 | .create_tool_calls_chunk(&ctx_guard.tool_calls); 655 | let json = serde_json::to_string(&tool_chunk).unwrap(); 656 | 657 | if !ctx_guard.role_chunk_sent { 658 | let role_chunk = generator.create_role_chunk(); 659 | let role_json = 660 | serde_json::to_string(&role_chunk).unwrap(); 661 | ctx_guard.role_chunk_sent = true; 662 | output_content = Some(format!( 663 | "data: {}\n\ndata: {}\n\n", 664 | role_json, json 665 | )); 666 | } else { 667 | output_content = 668 | Some(format!("data: {}\n\n", json)); 669 | } 670 | } 671 | } 672 | ChatEventType::Done => { 673 | // 如果 Done 事件返回了內容,表示有未處理的圖片引用 674 | if let Some(chunk_content) = chunk_content_opt { 675 | if chunk_content != "done" && !ctx_guard.image_urls_sent 676 | { 677 | debug!( 678 | "✅ Done 事件包含未處理的圖片引用,發送最終內容" 679 | ); 680 | let chunk = generator.create_stream_chunk( 681 | &chunk_content, 682 | Some("stop".to_string()), 683 | ); 684 | let json = serde_json::to_string(&chunk).unwrap(); 685 | output_content = 686 | Some(format!("data: {}\n\n", json)); 687 | ctx_guard.image_urls_sent = true; // 標記已發送 688 | } else { 689 | // 一般完成事件 690 | let ( 691 | prompt_tokens, 692 | completion_tokens, 693 | total_tokens, 694 | ) = generator.calculate_tokens(&mut ctx_guard); 695 | let finish_reason = 696 | if !ctx_guard.tool_calls.is_empty() { 697 | "tool_calls" 698 | } else { 699 | "stop" 700 | }; 701 | let final_chunk = generator.create_stream_chunk( 702 | "", 703 | Some(finish_reason.to_string()), 704 | ); 705 | let final_json = if generator.include_usage { 706 | debug!( 707 | "📊 Token 使用統計 | prompt_tokens: {} | completion_tokens: {} | total_tokens: {}", 708 | prompt_tokens, 709 | completion_tokens, 710 | total_tokens 711 | ); 712 | let mut json_value = 713 | serde_json::to_value(&final_chunk).unwrap(); 714 | json_value["usage"] = serde_json::json!({ 715 | "prompt_tokens": prompt_tokens, 716 | "completion_tokens": completion_tokens, 717 | "total_tokens": total_tokens, 718 | "prompt_tokens_details": {"cached_tokens": 0} 719 | }); 720 | serde_json::to_string(&json_value).unwrap() 721 | } else { 722 | serde_json::to_string(&final_chunk).unwrap() 723 | }; 724 | 725 | if !ctx_guard.role_chunk_sent { 726 | let role_chunk = generator.create_role_chunk(); 727 | let role_json = 728 | serde_json::to_string(&role_chunk).unwrap(); 729 | ctx_guard.role_chunk_sent = true; 730 | output_content = Some(format!( 731 | "data: {}\n\ndata: {}\n\n", 732 | role_json, final_json 733 | )); 734 | } else { 735 | output_content = 736 | Some(format!("data: {}\n\n", final_json)); 737 | } 738 | } 739 | } else { 740 | // 無內容的完成事件 741 | let (prompt_tokens, completion_tokens, total_tokens) = 742 | generator.calculate_tokens(&mut ctx_guard); 743 | let finish_reason = if !ctx_guard.tool_calls.is_empty() 744 | { 745 | "tool_calls" 746 | } else { 747 | "stop" 748 | }; 749 | let final_chunk = generator.create_stream_chunk( 750 | "", 751 | Some(finish_reason.to_string()), 752 | ); 753 | let final_json = if generator.include_usage { 754 | debug!( 755 | "📊 Token 使用統計 | prompt_tokens: {} | completion_tokens: {} | total_tokens: {}", 756 | prompt_tokens, completion_tokens, total_tokens 757 | ); 758 | let mut json_value = 759 | serde_json::to_value(&final_chunk).unwrap(); 760 | json_value["usage"] = serde_json::json!({ 761 | "prompt_tokens": prompt_tokens, 762 | "completion_tokens": completion_tokens, 763 | "total_tokens": total_tokens, 764 | "prompt_tokens_details": {"cached_tokens": 0} 765 | }); 766 | serde_json::to_string(&json_value).unwrap() 767 | } else { 768 | serde_json::to_string(&final_chunk).unwrap() 769 | }; 770 | 771 | if !ctx_guard.role_chunk_sent { 772 | let role_chunk = generator.create_role_chunk(); 773 | let role_json = 774 | serde_json::to_string(&role_chunk).unwrap(); 775 | ctx_guard.role_chunk_sent = true; 776 | output_content = Some(format!( 777 | "data: {}\n\ndata: {}\n\n", 778 | role_json, final_json 779 | )); 780 | } else { 781 | output_content = 782 | Some(format!("data: {}\n\n", final_json)); 783 | } 784 | } 785 | } 786 | _ => { 787 | // 其他事件類型,如果有返回內容也處理 788 | if let Some(chunk_content) = chunk_content_opt { 789 | if !ctx_guard.role_chunk_sent { 790 | let role_chunk = generator.create_role_chunk(); 791 | let role_json = 792 | serde_json::to_string(&role_chunk).unwrap(); 793 | ctx_guard.role_chunk_sent = true; 794 | 795 | let content_chunk = generator 796 | .create_stream_chunk(&chunk_content, None); 797 | let content_json = 798 | serde_json::to_string(&content_chunk).unwrap(); 799 | 800 | output_content = Some(format!( 801 | "data: {}\n\ndata: {}\n\n", 802 | role_json, content_json 803 | )); 804 | } else { 805 | let chunk = generator 806 | .create_stream_chunk(&chunk_content, None); 807 | let json = serde_json::to_string(&chunk).unwrap(); 808 | output_content = 809 | Some(format!("data: {}\n\n", json)); 810 | } 811 | } 812 | } 813 | } 814 | 815 | // 如果沒有輸出內容且需要發送角色塊,則發送 816 | if output_content.is_none() 817 | && !ctx_guard.role_chunk_sent 818 | && (event.event == ChatEventType::Text 819 | || event.event == ChatEventType::ReplaceResponse 820 | || event.event == ChatEventType::File) 821 | { 822 | let role_chunk = generator.create_role_chunk(); 823 | let role_json = serde_json::to_string(&role_chunk).unwrap(); 824 | ctx_guard.role_chunk_sent = true; 825 | output_content = Some(format!("data: {}\n\n", role_json)); 826 | } 827 | } 828 | 829 | // 返回輸出內容 830 | if let Some(output) = output_content { 831 | if !output.trim().is_empty() { 832 | debug!( 833 | "📤 發送串流片段 | 長度: {}", 834 | format_bytes_length(output.len()) 835 | ); 836 | Some(( 837 | Ok(output), 838 | ( 839 | event_stream, 840 | is_done, 841 | ctx_arc, 842 | handler_manager, 843 | generator, 844 | ), 845 | )) 846 | } else { 847 | // 空輸出,繼續處理 848 | Some(( 849 | Ok(String::new()), 850 | ( 851 | event_stream, 852 | is_done, 853 | ctx_arc, 854 | handler_manager, 855 | generator, 856 | ), 857 | )) 858 | } 859 | } else { 860 | // 沒有輸出,但繼續處理 861 | Some(( 862 | Ok(String::new()), 863 | (event_stream, is_done, ctx_arc, handler_manager, generator), 864 | )) 865 | } 866 | } 867 | Some(Err(e)) => { 868 | error!("❌ 串流處理錯誤: {}", e); 869 | let error_response = convert_poe_error_to_openai(&e.to_string(), false); 870 | let error_json = serde_json::to_string(&error_response.1).unwrap(); 871 | Some(( 872 | Ok(format!("data: {}\n\n", error_json)), 873 | (event_stream, true, ctx_arc, handler_manager, generator), 874 | )) 875 | } 876 | None => { 877 | debug!("⏹️ 事件流結束"); 878 | None 879 | } 880 | } 881 | } 882 | }, 883 | ); 884 | 885 | // 添加結束消息 886 | let done_message = "data: [DONE]\n\n".to_string(); 887 | 888 | // 過濾掉空的訊息,並加上結束訊息 889 | Box::pin( 890 | stream_processor 891 | .filter(|result| { 892 | future::ready(match result { 893 | Ok(s) => !s.is_empty(), 894 | Err(_) => true, 895 | }) 896 | }) 897 | .chain(stream::once(future::ready(Ok(done_message)))), 898 | ) 899 | } 900 | } 901 | -------------------------------------------------------------------------------- /src/handlers/cors.rs: -------------------------------------------------------------------------------- 1 | use salvo::http::{HeaderValue, Method, StatusCode, header}; 2 | use salvo::prelude::*; 3 | use tracing::{debug, info}; 4 | 5 | /// 检查头部是否安全 6 | fn is_safe_header(header: &str) -> bool { 7 | let header_lower = header.trim().to_lowercase(); 8 | 9 | // 排除空字符串 10 | if header_lower.is_empty() { 11 | return false; 12 | } 13 | 14 | // 黑名單:明確的惡意頭部 15 | if matches!(header_lower.as_str(), "cookie" | "set-cookie") { 16 | return false; 17 | } 18 | 19 | // 白名單:允許的頭部模式 20 | // 1. X-開頭的自定義頭部(如X-Stainless-*) 21 | // 2. 標準HTTP頭部 22 | header_lower.starts_with("x-") 23 | || matches!( 24 | header_lower.as_str(), 25 | "accept" 26 | | "accept-encoding" 27 | | "accept-language" 28 | | "authorization" 29 | | "cache-control" 30 | | "connection" 31 | | "content-type" 32 | | "user-agent" 33 | | "referer" 34 | | "origin" 35 | | "pragma" 36 | | "sec-fetch-dest" 37 | | "sec-fetch-mode" 38 | | "sec-fetch-site" 39 | ) 40 | } 41 | 42 | /// 解析客戶端請求的頭部並進行安全過濾 43 | fn parse_requested_headers(req: &Request) -> Vec { 44 | req.headers() 45 | .get(header::ACCESS_CONTROL_REQUEST_HEADERS) 46 | .and_then(|h| h.to_str().ok()) 47 | .map(|headers_str| { 48 | headers_str 49 | .split(',') 50 | .map(|h| h.trim().to_string()) 51 | .filter(|h| !h.is_empty() && is_safe_header(h)) 52 | .collect() 53 | }) 54 | .unwrap_or_default() 55 | } 56 | 57 | #[handler] 58 | pub async fn cors_middleware( 59 | req: &mut Request, 60 | depot: &mut Depot, 61 | res: &mut Response, 62 | ctrl: &mut FlowCtrl, 63 | ) { 64 | // 從請求中獲取Origin頭 65 | let origin = req 66 | .headers() 67 | .get(header::ORIGIN) 68 | .and_then(|h| h.to_str().ok()) 69 | .unwrap_or("null"); 70 | 71 | // 記錄請求的Origin用於調試 72 | debug!("📡 接收到來自Origin: {} 的請求", origin); 73 | 74 | // 設置CORS頭部 75 | match HeaderValue::from_str(origin) { 76 | Ok(origin_value) => { 77 | res.headers_mut() 78 | .insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin_value); 79 | } 80 | Err(e) => { 81 | debug!("⚠️ 無效的Origin頭: {}, 錯誤: {}", origin, e); 82 | res.headers_mut().insert( 83 | header::ACCESS_CONTROL_ALLOW_ORIGIN, 84 | HeaderValue::from_static("null"), 85 | ); 86 | } 87 | } 88 | 89 | res.headers_mut().insert( 90 | header::ACCESS_CONTROL_ALLOW_CREDENTIALS, 91 | HeaderValue::from_static("true"), 92 | ); 93 | 94 | // 為所有回應添加Vary頭,表明回應基於Origin頭變化 95 | res.headers_mut() 96 | .insert(header::VARY, HeaderValue::from_static("Origin")); 97 | 98 | // 如果是OPTIONS請求,直接處理並停止後續流程 99 | if req.method() == Method::OPTIONS { 100 | handle_preflight_request(req, res); 101 | ctrl.skip_rest(); 102 | } else { 103 | // 非OPTIONS請求,繼續正常流程 104 | ctrl.call_next(req, depot, res).await; 105 | } 106 | } 107 | 108 | /// 專門處理CORS預檢請求 109 | fn handle_preflight_request(req: &Request, res: &mut Response) { 110 | info!("🔍 處理OPTIONS預檢請求: {}", req.uri()); 111 | 112 | // 設置CORS預檢回應的標準頭部 113 | res.headers_mut().insert( 114 | header::ACCESS_CONTROL_ALLOW_METHODS, 115 | HeaderValue::from_static("GET, POST, OPTIONS, PUT, DELETE, PATCH, HEAD"), 116 | ); 117 | 118 | // 基礎硬編碼頭部(保持向後兼容) 119 | let base_headers = vec![ 120 | "Authorization", 121 | "Content-Type", 122 | "User-Agent", 123 | "Accept", 124 | "Origin", 125 | "X-Requested-With", 126 | "Access-Control-Request-Method", 127 | "Access-Control-Request-Headers", 128 | "Accept-Encoding", 129 | "Accept-Language", 130 | "Cache-Control", 131 | "Connection", 132 | "Referer", 133 | "Sec-Fetch-Dest", 134 | "Sec-Fetch-Mode", 135 | "Sec-Fetch-Site", 136 | "Pragma", 137 | "X-Api-Key", 138 | ]; 139 | 140 | // 解析客戶端請求的動態頭部 141 | let dynamic_headers = parse_requested_headers(req); 142 | 143 | // 合併基礎頭部和動態頭部 144 | let mut all_headers = base_headers.clone(); 145 | for header in &dynamic_headers { 146 | if !all_headers 147 | .iter() 148 | .any(|h| h.to_lowercase() == header.to_lowercase()) 149 | { 150 | all_headers.push(header); 151 | } 152 | } 153 | 154 | // 構建最終的頭部字符串 155 | let headers_str = all_headers.join(", "); 156 | 157 | // 記錄調試信息 158 | if !dynamic_headers.is_empty() { 159 | info!("➕ 動態添加的頭部: {:?}", dynamic_headers); 160 | } 161 | info!("📋 最終允許的頭部: {}", headers_str); 162 | 163 | // 設置 Access-Control-Allow-Headers 164 | match HeaderValue::from_str(&headers_str) { 165 | Ok(headers_value) => { 166 | res.headers_mut() 167 | .insert(header::ACCESS_CONTROL_ALLOW_HEADERS, headers_value); 168 | } 169 | Err(e) => { 170 | // 降級處理:如果動態頭部有問題,使用基礎頭部 171 | debug!("⚠️ 動態頭部設置失敗: {}, 使用基礎頭部", e); 172 | res.headers_mut().insert( 173 | header::ACCESS_CONTROL_ALLOW_HEADERS, 174 | HeaderValue::from_static( 175 | "Authorization, Content-Type, User-Agent, Accept, Origin, \ 176 | X-Requested-With, Access-Control-Request-Method, \ 177 | Access-Control-Request-Headers, Accept-Encoding, Accept-Language, \ 178 | Cache-Control, Connection, Referer, Sec-Fetch-Dest, Sec-Fetch-Mode, \ 179 | Sec-Fetch-Site, Pragma, X-Api-Key", 180 | ), 181 | ); 182 | } 183 | } 184 | 185 | res.headers_mut().insert( 186 | header::ACCESS_CONTROL_MAX_AGE, 187 | HeaderValue::from_static("3600"), 188 | ); 189 | 190 | // 添加Vary頭,表明回應會根據這些請求頭變化 191 | res.headers_mut().insert( 192 | header::VARY, 193 | HeaderValue::from_static("Access-Control-Request-Method, Access-Control-Request-Headers"), 194 | ); 195 | 196 | // 設置正確的狀態碼: 204 No Content 197 | res.status_code(StatusCode::NO_CONTENT); 198 | } 199 | -------------------------------------------------------------------------------- /src/handlers/limit.rs: -------------------------------------------------------------------------------- 1 | use salvo::prelude::*; 2 | use std::sync::Arc; 3 | use std::time::{Duration, Instant}; 4 | use tokio::sync::Mutex; 5 | use tokio::time::sleep; 6 | use tracing::debug; 7 | 8 | // 全局變量,將在 main.rs 中初始化 9 | pub static GLOBAL_RATE_LIMITER: tokio::sync::OnceCell>> = 10 | tokio::sync::OnceCell::const_new(); 11 | 12 | /// 取得速率限制間隔 (毫秒) 13 | /// 返回 None 表示禁用速率限制 14 | fn get_rate_limit_ms() -> Option { 15 | let ms = std::env::var("RATE_LIMIT_MS") 16 | .ok() 17 | .and_then(|s| s.parse::().ok()) 18 | .unwrap_or(100); 19 | 20 | // 如果值為 0,表示禁用速率限制 21 | if ms == 0 { 22 | None 23 | } else { 24 | Some(Duration::from_millis(ms)) 25 | } 26 | } 27 | 28 | #[handler] 29 | pub async fn rate_limit_middleware( 30 | req: &mut Request, 31 | depot: &mut Depot, 32 | res: &mut Response, 33 | ctrl: &mut FlowCtrl, 34 | ) { 35 | // 獲取速率限制間隔,None 表示禁用 36 | if let Some(interval) = get_rate_limit_ms() { 37 | if let Some(cell) = GLOBAL_RATE_LIMITER.get() { 38 | let mut lock = cell.lock().await; 39 | let now = Instant::now(); 40 | let elapsed = now.duration_since(*lock); 41 | 42 | if elapsed < interval { 43 | let wait = interval - elapsed; 44 | debug!( 45 | "⏳ 請求觸發全局速率限制,延遲 {:?},間隔設定: {:?}", 46 | wait, interval 47 | ); 48 | sleep(wait).await; 49 | } 50 | 51 | *lock = Instant::now(); 52 | } 53 | } else { 54 | debug!("🚫 全局速率限制已禁用 (RATE_LIMIT_MS=0)"); 55 | } 56 | 57 | ctrl.call_next(req, depot, res).await; 58 | } 59 | -------------------------------------------------------------------------------- /src/handlers/mod.rs: -------------------------------------------------------------------------------- 1 | mod admin; 2 | mod chat; 3 | mod cors; 4 | pub(crate) mod limit; 5 | mod models; 6 | 7 | pub use admin::admin_routes; 8 | pub use chat::chat_completions; 9 | pub use cors::cors_middleware; 10 | pub use limit::rate_limit_middleware; 11 | pub use models::get_models; 12 | -------------------------------------------------------------------------------- /src/handlers/models.rs: -------------------------------------------------------------------------------- 1 | use crate::{cache::get_cached_config, types::*}; 2 | use chrono::Utc; 3 | use poe_api_process::{ModelInfo, get_model_list}; 4 | use salvo::prelude::*; 5 | use serde_json::json; 6 | use std::collections::HashSet; 7 | use std::sync::Arc; 8 | use std::time::Instant; 9 | use tokio::sync::RwLock; 10 | use tracing::{debug, error, info}; 11 | 12 | // 注意:此緩存不適用於 /api/models 路徑 13 | static API_MODELS_CACHE: RwLock>>> = RwLock::const_new(None); 14 | 15 | #[handler] 16 | pub async fn get_models(req: &mut Request, res: &mut Response) { 17 | let path = req.uri().path(); 18 | info!("📋 收到獲取模型列表請求 | 路徑: {}", path); 19 | let start_time = Instant::now(); 20 | 21 | // 處理 /api/models 特殊路徑 (不使用緩存) --- 22 | if path == "/api/models" { 23 | info!("⚡️ api/models 路徑:直接從 Poe 取得(無緩存)"); 24 | match get_model_list(Some("zh-Hant")).await { 25 | Ok(model_list) => { 26 | let lowercase_models = model_list 27 | .data 28 | .into_iter() 29 | .map(|mut model| { 30 | model.id = model.id.to_lowercase(); 31 | model 32 | }) 33 | .collect::>(); 34 | 35 | let models_arc = Arc::new(lowercase_models); 36 | 37 | { 38 | let mut cache_guard = API_MODELS_CACHE.write().await; 39 | *cache_guard = Some(models_arc.clone()); 40 | info!("🔄 Updated API_MODELS_CACHE after /api/models request."); 41 | } 42 | 43 | let response = json!({ 44 | "object": "list", 45 | "data": &*models_arc 46 | }); 47 | 48 | let duration = start_time.elapsed(); 49 | info!( 50 | "✅ [/api/models] 成功獲取未過濾模型列表並更新緩存 | 模型數量: {} | 處理時間: {}", 51 | models_arc.len(), // 使用 Arc 的長度 52 | crate::utils::format_duration(duration) 53 | ); 54 | res.render(Json(response)); 55 | } 56 | Err(e) => { 57 | let duration = start_time.elapsed(); 58 | error!( 59 | "❌ [/api/models] 獲取模型列表失敗 | 錯誤: {} | 耗時: {}", 60 | e, 61 | crate::utils::format_duration(duration) 62 | ); 63 | res.status_code(StatusCode::INTERNAL_SERVER_ERROR); 64 | res.render(Json(json!({ "error": e.to_string() }))); 65 | } 66 | } 67 | return; 68 | } 69 | 70 | let config = get_cached_config().await; 71 | 72 | let is_enabled = config.enable.unwrap_or(false); 73 | debug!("🔍 設定檔啟用狀態 (來自緩存): {}", is_enabled); 74 | 75 | let yaml_config_map: std::collections::HashMap = config 76 | .models 77 | .clone() // Clone HashMap from Arc 78 | .into_iter() 79 | .map(|(k, v)| (k.to_lowercase(), v)) 80 | .collect(); 81 | 82 | if is_enabled { 83 | info!("⚙️ 合併緩存的 Poe API 列表與 models.yaml (啟用)"); 84 | 85 | let api_models_data_arc: Arc>; 86 | 87 | let read_guard = API_MODELS_CACHE.read().await; 88 | if let Some(cached_data) = &*read_guard { 89 | // 緩存命中 90 | debug!("✅ 模型緩存命中。"); 91 | api_models_data_arc = cached_data.clone(); 92 | drop(read_guard); 93 | } else { 94 | // 緩存未命中 95 | debug!("❌ 模型緩存未命中。正在嘗試填充..."); 96 | drop(read_guard); 97 | 98 | let mut write_guard = API_MODELS_CACHE.write().await; 99 | // 再次檢查,防止在獲取寫入鎖期間其他線程已填充緩存 100 | if let Some(cached_data) = &*write_guard { 101 | debug!("✅ API 模型緩存在等待寫入鎖時由另一個執行緒填充。"); 102 | api_models_data_arc = cached_data.clone(); 103 | } else { 104 | // 緩存確實是空的,從 API 獲取數據 105 | info!("⏳ 從 API 取得模型以填充快取中……"); 106 | match get_model_list(Some("zh-Hant")).await { 107 | Ok(list) => { 108 | let lowercase_models = list 109 | .data 110 | .into_iter() 111 | .map(|mut model| { 112 | model.id = model.id.to_lowercase(); 113 | model 114 | }) 115 | .collect::>(); 116 | let new_data = Arc::new(lowercase_models); 117 | *write_guard = Some(new_data.clone()); 118 | api_models_data_arc = new_data; 119 | info!("✅ API models cache populated successfully."); 120 | } 121 | Err(e) => { 122 | // 如果填充緩存失敗,返回錯誤 123 | let duration = start_time.elapsed(); // 計算耗時 124 | error!( 125 | "❌ 無法填充 API 模型快取:{} | 耗時:{}。", 126 | e, 127 | crate::utils::format_duration(duration) // 在日誌中使用 duration 128 | ); 129 | res.status_code(StatusCode::INTERNAL_SERVER_ERROR); 130 | res.render(Json( 131 | json!({ "error": format!("未能檢索模型列表以填充快取:{}", e) }), 132 | )); 133 | drop(write_guard); 134 | return; 135 | } 136 | } 137 | } 138 | drop(write_guard); 139 | } 140 | 141 | let mut api_model_ids: HashSet = HashSet::new(); 142 | for model_ref in api_models_data_arc.iter() { 143 | api_model_ids.insert(model_ref.id.to_lowercase()); 144 | } 145 | 146 | let mut processed_models_enabled: Vec = Vec::new(); 147 | 148 | for api_model_ref in api_models_data_arc.iter() { 149 | let api_model_id_lower = api_model_ref.id.to_lowercase(); 150 | match yaml_config_map.get(&api_model_id_lower) { 151 | Some(yaml_config) => { 152 | // 在 YAML 中找到:檢查是否啟用,若啟用則應用 mapping 153 | if yaml_config.enable.unwrap_or(true) { 154 | let final_id = if let Some(mapping) = &yaml_config.mapping { 155 | let new_id = mapping.to_lowercase(); 156 | debug!( 157 | "🔄 API 模型改名 (YAML 啟用): {} -> {}", 158 | api_model_id_lower, new_id 159 | ); 160 | new_id 161 | } else { 162 | debug!( 163 | "✅ 保留 API 模型 (YAML 啟用,無 mapping): {}", 164 | api_model_id_lower 165 | ); 166 | api_model_id_lower.clone() 167 | }; 168 | processed_models_enabled.push(ModelInfo { 169 | id: final_id, 170 | object: api_model_ref.object.clone(), 171 | created: api_model_ref.created, 172 | owned_by: api_model_ref.owned_by.clone(), 173 | }); 174 | } else { 175 | debug!("❌ 排除 API 模型 (YAML 停用): {}", api_model_id_lower); 176 | } 177 | } 178 | None => { 179 | debug!("✅ 保留 API 模型 (不在 YAML 中): {}", api_model_id_lower); 180 | processed_models_enabled.push(ModelInfo { 181 | id: api_model_id_lower.clone(), 182 | object: api_model_ref.object.clone(), 183 | created: api_model_ref.created, 184 | owned_by: api_model_ref.owned_by.clone(), 185 | }); 186 | } 187 | } 188 | } 189 | 190 | // 處理自訂模型,將其添加到已處理的模型列表中 191 | if let Some(custom_models) = &config.custom_models { 192 | if !custom_models.is_empty() { 193 | info!("📋 處理自訂模型 | 數量: {}", custom_models.len()); 194 | for custom_model in custom_models { 195 | let model_id = custom_model.id.to_lowercase(); 196 | // 檢查該ID是否已存在於處理後的模型中 197 | if !processed_models_enabled.iter().any(|m| m.id == model_id) { 198 | // 檢查是否在 yaml_config_map 中配置了 enable: false 199 | if let Some(yaml_config) = yaml_config_map.get(&model_id) { 200 | if yaml_config.enable == Some(false) { 201 | debug!("❌ 排除自訂模型 (YAML 停用): {}", model_id); 202 | continue; 203 | } 204 | } 205 | 206 | debug!("➕ 添加自訂模型: {}", model_id); 207 | processed_models_enabled.push(ModelInfo { 208 | id: model_id, 209 | object: "model".to_string(), 210 | created: custom_model 211 | .created 212 | .unwrap_or_else(|| Utc::now().timestamp()), 213 | owned_by: custom_model 214 | .owned_by 215 | .clone() 216 | .unwrap_or_else(|| "poe".to_string()), 217 | }); 218 | } 219 | } 220 | } 221 | } 222 | 223 | let response = json!({ 224 | "object": "list", 225 | "data": processed_models_enabled 226 | }); 227 | 228 | let duration = start_time.elapsed(); 229 | info!( 230 | "✅ 成功獲取處理後模型列表 | 來源: {} | 模型數量: {} | 處理時間: {}", 231 | "YAML + Cached API", 232 | processed_models_enabled.len(), 233 | crate::utils::format_duration(duration) 234 | ); 235 | 236 | res.render(Json(response)); 237 | } else { 238 | info!("🔌 YAML 停用,直接從 Poe API 獲取模型列表 (無緩存,無 YAML 規則)..."); 239 | 240 | match get_model_list(Some("zh-Hant")).await { 241 | Ok(model_list) => { 242 | let lowercase_models = model_list 243 | .data 244 | .into_iter() 245 | .map(|mut model| { 246 | model.id = model.id.to_lowercase(); 247 | model 248 | }) 249 | .collect::>(); 250 | 251 | let response = json!({ 252 | "object": "list", 253 | "data": lowercase_models 254 | }); 255 | let duration = start_time.elapsed(); 256 | info!( 257 | "✅ [直連 Poe] 成功直接獲取模型列表 | 模型數量: {} | 處理時間: {}", 258 | lowercase_models.len(), 259 | crate::utils::format_duration(duration) 260 | ); 261 | res.render(Json(response)); 262 | } 263 | Err(e) => { 264 | let duration = start_time.elapsed(); 265 | error!( 266 | "❌ [直連 Poe] 直接獲取模型列表失敗 | 錯誤: {} | 耗時: {}", 267 | e, 268 | crate::utils::format_duration(duration) 269 | ); 270 | res.status_code(StatusCode::INTERNAL_SERVER_ERROR); 271 | res.render(Json( 272 | json!({ "error": format!("無法直接從API獲取模型:{}", e) }), 273 | )); 274 | } 275 | } 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use salvo::prelude::*; 2 | use std::env; 3 | use std::path::Path; 4 | use std::sync::Arc; 5 | use std::time::Duration; 6 | use tracing::{debug, info}; 7 | 8 | mod cache; 9 | mod evert; 10 | mod handlers; 11 | mod poe_client; 12 | mod types; 13 | mod utils; 14 | 15 | fn get_env_or_default(key: &str, default: &str) -> String { 16 | let value = env::var(key).unwrap_or_else(|_| default.to_string()); 17 | if key == "ADMIN_PASSWORD" { 18 | debug!("🔧 環境變數 {} = {}", key, "*".repeat(value.len())); 19 | } else { 20 | debug!("🔧 環境變數 {} = {}", key, value); 21 | } 22 | value 23 | } 24 | 25 | fn setup_logging(log_level: &str) { 26 | tracing_subscriber::fmt() 27 | .with_target(false) 28 | .with_thread_ids(true) 29 | .with_level(true) 30 | .with_file(false) 31 | .with_line_number(false) 32 | .with_env_filter(log_level) 33 | .init(); 34 | info!("🚀 日誌系統初始化完成,日誌級別: {}", log_level); 35 | } 36 | 37 | fn log_cache_settings() { 38 | // 記錄緩存相關設定 39 | let cache_ttl_seconds = std::env::var("URL_CACHE_TTL_SECONDS") 40 | .ok() 41 | .and_then(|s| s.parse::().ok()) 42 | .unwrap_or(3 * 24 * 60 * 60); 43 | let cache_size_mb = std::env::var("URL_CACHE_SIZE_MB") 44 | .ok() 45 | .and_then(|s| s.parse::().ok()) 46 | .unwrap_or(100); 47 | 48 | let ttl_days = cache_ttl_seconds / 86400; 49 | let ttl_hours = (cache_ttl_seconds % 86400) / 3600; 50 | let ttl_mins = (cache_ttl_seconds % 3600) / 60; 51 | let ttl_secs = cache_ttl_seconds % 60; 52 | 53 | let ttl_str = if ttl_days > 0 { 54 | format!( 55 | "{}天 {}小時 {}分 {}秒", 56 | ttl_days, ttl_hours, ttl_mins, ttl_secs 57 | ) 58 | } else if ttl_hours > 0 { 59 | format!("{}小時 {}分 {}秒", ttl_hours, ttl_mins, ttl_secs) 60 | } else if ttl_mins > 0 { 61 | format!("{}分 {}秒", ttl_mins, ttl_secs) 62 | } else { 63 | format!("{}秒", ttl_secs) 64 | }; 65 | 66 | info!( 67 | "📦 Poe CDN URL 緩存設定 | TTL: {} | 最大空間: {}MB", 68 | ttl_str, cache_size_mb 69 | ); 70 | } 71 | 72 | #[tokio::main] 73 | async fn main() { 74 | let log_level = get_env_or_default("LOG_LEVEL", "debug"); 75 | setup_logging(&log_level); 76 | 77 | // 初始化緩存設定 78 | log_cache_settings(); 79 | 80 | // 初始化全域速率限制 81 | let _ = handlers::limit::GLOBAL_RATE_LIMITER.set(Arc::new(tokio::sync::Mutex::new( 82 | std::time::Instant::now() - Duration::from_secs(60), 83 | ))); 84 | 85 | // 顯示速率限制設定 86 | let rate_limit_ms = std::env::var("RATE_LIMIT_MS") 87 | .ok() 88 | .and_then(|s| s.parse::().ok()) 89 | .unwrap_or(100); 90 | 91 | if rate_limit_ms == 0 { 92 | info!("⚙️ 全域速率限制: 已禁用 (RATE_LIMIT_MS=0)"); 93 | } else { 94 | info!("⚙️ 全域速率限制: 已啟用 (每 {}ms 一次請求)", rate_limit_ms); 95 | } 96 | 97 | let host = get_env_or_default("HOST", "0.0.0.0"); 98 | let port = get_env_or_default("PORT", "8080"); 99 | get_env_or_default("ADMIN_USERNAME", "admin"); 100 | get_env_or_default("ADMIN_PASSWORD", "123456"); 101 | let config_dir = get_env_or_default("CONFIG_DIR", "./"); 102 | let config_path = Path::new(&config_dir).join("models.yaml"); 103 | info!("📁 配置文件路徑: {}", config_path.display()); 104 | 105 | let salvo_max_size = get_env_or_default("MAX_REQUEST_SIZE", "1073741824") 106 | .parse() 107 | .unwrap_or(1024 * 1024 * 1024); // 預設 1GB 108 | 109 | let bind_address = format!("{}:{}", host, port); 110 | info!("🌟 正在啟動 Poe API To OpenAI API 服務..."); 111 | debug!("📍 服務綁定地址: {}", bind_address); 112 | 113 | // 初始化Sled DB 114 | let _ = cache::get_sled_db(); 115 | info!("💾 初始化內存數據庫完成"); 116 | 117 | let api_router = Router::new() 118 | .hoop(handlers::cors_middleware) 119 | .push( 120 | Router::with_path("models") 121 | .get(handlers::get_models) 122 | .options(handlers::cors_middleware), 123 | ) 124 | .push( 125 | Router::with_path("chat/completions") 126 | .hoop(handlers::rate_limit_middleware) 127 | .post(handlers::chat_completions) 128 | .options(handlers::cors_middleware), 129 | ) 130 | .push( 131 | Router::with_path("api/models") 132 | .get(handlers::get_models) 133 | .options(handlers::cors_middleware), 134 | ) 135 | .push( 136 | Router::with_path("v1/models") 137 | .get(handlers::get_models) 138 | .options(handlers::cors_middleware), 139 | ) 140 | .push( 141 | Router::with_path("v1/chat/completions") 142 | .hoop(handlers::rate_limit_middleware) 143 | .post(handlers::chat_completions) 144 | .options(handlers::cors_middleware), 145 | ); 146 | 147 | let router: Router = Router::new() 148 | .hoop(max_size(salvo_max_size.try_into().unwrap())) 149 | .push(Router::with_path("static/{**path}").get(StaticDir::new(["static"]))) 150 | .push(handlers::admin_routes()) 151 | .push(api_router); 152 | 153 | info!("🛣️ API 路由配置完成"); 154 | 155 | let acceptor = TcpListener::new(&bind_address).bind().await; 156 | info!("🎯 服務已啟動並監聽於 {}", bind_address); 157 | 158 | Server::new(acceptor).serve(router).await; 159 | } 160 | -------------------------------------------------------------------------------- /src/poe_client.rs: -------------------------------------------------------------------------------- 1 | use crate::{cache::get_cached_config, types::*, utils::get_text_from_openai_content}; 2 | use futures_util::Stream; 3 | use poe_api_process::types::Attachment; 4 | use poe_api_process::{ChatMessage, ChatRequest, ChatResponse, PoeClient, PoeError}; 5 | use std::collections::HashMap; 6 | use std::pin::Pin; 7 | use std::sync::Arc; 8 | use std::time::Instant; 9 | use tracing::{debug, error, info}; 10 | 11 | pub struct PoeClientWrapper { 12 | pub client: PoeClient, // 修改為公開,以便外部訪問 13 | _model: String, 14 | } 15 | 16 | impl PoeClientWrapper { 17 | pub fn new(model: &str, access_key: &str) -> Self { 18 | info!("🔑 初始化 POE 客戶端 | 模型: {}", model); 19 | Self { 20 | client: PoeClient::new(model, access_key), 21 | _model: model.to_string(), 22 | } 23 | } 24 | pub async fn stream_request( 25 | &self, 26 | chat_request: ChatRequest, 27 | ) -> Result> + Send>>, PoeError> { 28 | let start_time = Instant::now(); 29 | debug!( 30 | "📤 發送串流請求 | 訊息數量: {} | 溫度設置: {:?}", 31 | chat_request.query.len(), 32 | chat_request.temperature 33 | ); 34 | let result = self.client.stream_request(chat_request).await; 35 | match &result { 36 | Ok(_) => { 37 | let duration = start_time.elapsed(); 38 | info!( 39 | "✅ 串流請求建立成功 | 耗時: {}", 40 | crate::utils::format_duration(duration) 41 | ); 42 | } 43 | Err(e) => { 44 | let duration = start_time.elapsed(); 45 | error!( 46 | "❌ 串流請求失敗 | 錯誤: {} | 耗時: {}", 47 | e, 48 | crate::utils::format_duration(duration) 49 | ); 50 | } 51 | } 52 | result 53 | } 54 | } 55 | 56 | // OpenAI 消息格式轉換為 Poe 消息格式的函數 57 | fn openai_message_to_poe(msg: &Message, role_override: Option) -> ChatMessage { 58 | let mut attachments: Vec = vec![]; 59 | let mut texts: Vec = vec![]; 60 | 61 | match &msg.content { 62 | OpenAiContent::Text(s) => { 63 | texts.push(s.clone()); 64 | } 65 | OpenAiContent::Multi(arr) => { 66 | for item in arr { 67 | match item { 68 | OpenAiContentItem::Text { text } => texts.push(text.clone()), 69 | OpenAiContentItem::ImageUrl { image_url } => { 70 | debug!("🖼️ 處理圖片 URL: {}", image_url.url); 71 | attachments.push(Attachment { 72 | url: image_url.url.clone(), 73 | content_type: None, 74 | }); 75 | } 76 | } 77 | } 78 | } 79 | } 80 | 81 | let role = role_override.unwrap_or_else(|| msg.role.clone()); 82 | ChatMessage { 83 | role, 84 | content: texts.join("\n"), 85 | attachments: if !attachments.is_empty() { 86 | debug!("📎 添加 {} 個附件到消息", attachments.len()); 87 | Some(attachments) 88 | } else { 89 | None 90 | }, 91 | content_type: "text/markdown".to_string(), 92 | } 93 | } 94 | 95 | pub async fn create_chat_request( 96 | model: &str, 97 | messages: Vec, 98 | temperature: Option, 99 | tools: Option>, 100 | logit_bias: Option>, 101 | stop: Option>, 102 | ) -> ChatRequest { 103 | debug!( 104 | "📝 創建聊天請求 | 模型: {} | 訊息數量: {} | 溫度設置: {:?} | 工具數量: {:?}", 105 | model, 106 | messages.len(), 107 | temperature, 108 | tools.as_ref().map(|t| t.len()) 109 | ); 110 | // 從緩存獲取 models.yaml 配置 111 | let config: Arc = get_cached_config().await; 112 | // 檢查模型是否需要 replace_response 處理 113 | let should_replace_response = if let Some(model_config) = config.models.get(model) { 114 | // 使用快取的 config 115 | model_config.replace_response.unwrap_or(false) 116 | } else { 117 | false 118 | }; 119 | debug!( 120 | "🔍 模型 {} 的 replace_response 設置: {}", 121 | model, should_replace_response 122 | ); 123 | let query = messages 124 | .iter() 125 | .map(|msg| { 126 | let original_role = &msg.role; 127 | let role_override = match original_role.as_str() { 128 | // 總是將 assistant 轉換為 bot 129 | "assistant" => Some("bot".to_string()), 130 | // 總是將 developer 轉換為 user 131 | "developer" => Some("user".to_string()), 132 | // 只有在 replace_response 為 true 時才轉換 system 為 user 133 | "system" if should_replace_response => Some("user".to_string()), 134 | // 其他情況保持原樣 135 | _ => None, 136 | }; 137 | // 將 OpenAI 消息轉換為 Poe 消息 138 | let poe_message = openai_message_to_poe(msg, role_override); 139 | // 紀錄轉換結果 140 | debug!( 141 | "🔄 處理訊息 | 原始角色: {} | 轉換後角色: {} | 內容長度: {} | 附件數量: {}", 142 | original_role, 143 | poe_message.role, 144 | crate::utils::format_bytes_length(poe_message.content.len()), 145 | poe_message.attachments.as_ref().map_or(0, |a| a.len()) 146 | ); 147 | poe_message 148 | }) 149 | .collect(); 150 | // 處理工具結果消息 151 | let mut tool_results = None; 152 | // 檢查是否有 tool 角色的消息,並將其轉換為 ToolResult 153 | if messages.iter().any(|msg| msg.role == "tool") { 154 | let mut results = Vec::new(); 155 | for msg in &messages { 156 | if msg.role == "tool" { 157 | // 從內容中提取文字部分 158 | let content_text = get_text_from_openai_content(&msg.content); 159 | if let Some(tool_call_id) = extract_tool_call_id(&content_text) { 160 | debug!("🔧 處理工具結果 | tool_call_id: {}", tool_call_id); 161 | results.push(poe_api_process::types::ChatToolResult { 162 | role: "tool".to_string(), 163 | tool_call_id, 164 | name: "unknown".to_string(), 165 | content: content_text, 166 | }); 167 | } else { 168 | debug!("⚠️ 無法從工具消息中提取 tool_call_id"); 169 | } 170 | } 171 | } 172 | if !results.is_empty() { 173 | tool_results = Some(results); 174 | debug!( 175 | "🔧 創建了 {} 個工具結果", 176 | tool_results.as_ref().unwrap().len() 177 | ); 178 | } 179 | } 180 | ChatRequest { 181 | version: "1.1".to_string(), 182 | r#type: "query".to_string(), 183 | query, 184 | temperature, 185 | user_id: "".to_string(), 186 | conversation_id: "".to_string(), 187 | message_id: "".to_string(), 188 | tools, 189 | tool_calls: None, 190 | tool_results, 191 | logit_bias, 192 | stop_sequences: stop, 193 | } 194 | } 195 | 196 | // 從工具消息中提取 tool_call_id 197 | fn extract_tool_call_id(content: &str) -> Option { 198 | // 嘗試解析 JSON 格式的內容 199 | if let Ok(json) = serde_json::from_str::(content) { 200 | if let Some(tool_call_id) = json.get("tool_call_id").and_then(|v| v.as_str()) { 201 | return Some(tool_call_id.to_string()); 202 | } 203 | } 204 | // 嘗試使用簡單的文本解析 205 | if let Some(start) = content.find("tool_call_id") { 206 | if let Some(id_start) = content[start..].find('"') { 207 | if let Some(id_end) = content[start + id_start + 1..].find('"') { 208 | return Some( 209 | content[start + id_start + 1..start + id_start + 1 + id_end].to_string(), 210 | ); 211 | } 212 | } 213 | } 214 | None 215 | } 216 | -------------------------------------------------------------------------------- /src/types.rs: -------------------------------------------------------------------------------- 1 | use poe_api_process::types::{ChatTool, ChatToolCall}; 2 | use serde::{Deserialize, Serialize}; 3 | use std::collections::HashMap; 4 | 5 | #[derive(Deserialize)] 6 | pub struct ChatCompletionRequest { 7 | pub model: String, 8 | pub messages: Vec, 9 | #[serde(skip_serializing_if = "Option::is_none")] 10 | pub temperature: Option, 11 | #[serde(skip_serializing_if = "Option::is_none")] 12 | pub logit_bias: Option>, 13 | #[serde(skip_serializing_if = "Option::is_none")] 14 | pub stop: Option>, 15 | pub stream: Option, 16 | #[serde(skip_serializing_if = "Option::is_none")] 17 | pub tools: Option>, 18 | pub stream_options: Option, 19 | } 20 | 21 | #[derive(Deserialize)] 22 | pub struct StreamOptions { 23 | pub include_usage: Option, 24 | } 25 | 26 | // 定義支援 OpenAI content 格式的 enum (String 或陣列) 27 | #[derive(Debug, Deserialize, Clone)] 28 | #[serde(untagged)] 29 | pub enum OpenAiContent { 30 | Text(String), 31 | Multi(Vec), 32 | } 33 | 34 | // 定義 OpenAI content 陣列內的項目類型 35 | #[derive(Debug, Deserialize, Clone)] 36 | #[serde(tag = "type")] 37 | pub enum OpenAiContentItem { 38 | #[serde(rename = "text")] 39 | Text { text: String }, 40 | #[serde(rename = "image_url")] 41 | ImageUrl { image_url: ImageUrlContent }, 42 | } 43 | 44 | // 定義 image_url 的內容結構 45 | #[derive(Debug, Deserialize, Clone)] 46 | pub struct ImageUrlContent { 47 | pub url: String, 48 | // 可擴展其他欄位如 detail 等 49 | } 50 | 51 | // 更新 Message 結構使用新的 OpenAiContent 52 | #[derive(Deserialize, Clone)] 53 | pub struct Message { 54 | pub role: String, 55 | pub content: OpenAiContent, 56 | } 57 | 58 | #[derive(Serialize)] 59 | pub struct ChatCompletionResponse { 60 | pub id: String, 61 | pub object: String, 62 | pub created: i64, 63 | pub model: String, 64 | pub choices: Vec, 65 | pub usage: Option, 66 | } 67 | 68 | #[derive(Serialize)] 69 | pub struct CompletionChoice { 70 | pub index: u32, 71 | pub message: CompletionMessage, 72 | pub logprobs: Option, 73 | pub finish_reason: Option, 74 | } 75 | 76 | #[derive(Serialize)] 77 | pub struct CompletionMessage { 78 | pub role: String, 79 | pub content: String, 80 | pub refusal: Option, 81 | #[serde(skip_serializing_if = "Option::is_none")] 82 | pub tool_calls: Option>, 83 | } 84 | 85 | #[derive(Serialize)] 86 | pub struct ChatCompletionChunk { 87 | pub id: String, 88 | pub object: String, 89 | pub created: i64, 90 | pub model: String, 91 | pub choices: Vec, 92 | } 93 | 94 | #[derive(Serialize)] 95 | pub struct Choice { 96 | pub index: u32, 97 | pub delta: Delta, 98 | pub finish_reason: Option, 99 | } 100 | 101 | #[derive(Serialize)] 102 | pub struct Delta { 103 | pub role: Option, 104 | pub content: Option, 105 | pub refusal: Option, 106 | #[serde(skip_serializing_if = "Option::is_none")] 107 | pub tool_calls: Option>, 108 | } 109 | 110 | #[derive(Serialize, Clone, Debug)] 111 | pub struct OpenAIErrorResponse { 112 | pub error: OpenAIError, 113 | } 114 | 115 | #[derive(Serialize, Clone, Debug)] 116 | pub struct OpenAIError { 117 | pub message: String, 118 | pub r#type: String, 119 | pub code: String, 120 | pub param: Option, 121 | } 122 | 123 | #[derive(Default, Serialize, Deserialize)] 124 | pub(crate) struct Config { 125 | pub(crate) enable: Option, 126 | pub(crate) models: std::collections::HashMap, 127 | #[serde(skip_serializing_if = "Option::is_none")] 128 | pub(crate) custom_models: Option>, 129 | } 130 | 131 | #[derive(Serialize, Deserialize, Clone)] 132 | pub(crate) struct CustomModel { 133 | pub(crate) id: String, 134 | pub(crate) created: Option, 135 | pub(crate) owned_by: Option, 136 | } 137 | 138 | #[derive(Serialize, Deserialize, Default, Clone)] 139 | pub(crate) struct ModelConfig { 140 | #[serde(skip_serializing_if = "Option::is_none")] 141 | pub(crate) mapping: Option, 142 | #[serde(skip_serializing_if = "Option::is_none")] 143 | pub(crate) replace_response: Option, 144 | #[serde(skip_serializing_if = "Option::is_none")] 145 | pub(crate) enable: Option, 146 | } 147 | 148 | #[derive(Clone, Debug, Serialize, Deserialize)] 149 | pub struct CachedUrl { 150 | pub poe_url: String, 151 | pub size_bytes: usize, 152 | } 153 | 154 | #[derive(Clone, Debug, Serialize, Deserialize, Default)] 155 | pub struct UrlCache { 156 | pub external_urls: HashMap, 157 | pub base64_hashes: HashMap, 158 | pub total_size_bytes: usize, 159 | } 160 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use crate::poe_client::PoeClientWrapper; 2 | use crate::types::{Config, ImageUrlContent, Message, OpenAiContent, OpenAiContentItem}; 3 | use crate::types::{OpenAIError, OpenAIErrorResponse}; 4 | use base64::prelude::*; 5 | use nanoid::nanoid; 6 | use poe_api_process::FileUploadRequest; 7 | use salvo::http::StatusCode; 8 | use sha2::{Digest, Sha256}; 9 | use std::fs; 10 | use std::path::PathBuf; 11 | use tiktoken_rs::o200k_base; 12 | use tracing::{debug, error, info, warn}; 13 | 14 | // 處理消息中的文件/圖片 15 | pub async fn process_message_images( 16 | poe_client: &PoeClientWrapper, 17 | messages: &mut [Message], 18 | ) -> Result<(), Box> { 19 | // 收集需要處理的URL 20 | let mut external_urls = Vec::new(); 21 | let mut data_urls = Vec::new(); 22 | let mut url_indices = Vec::new(); 23 | let mut data_url_indices = Vec::new(); 24 | let mut temp_files: Vec = Vec::new(); 25 | 26 | // 收集消息中所有需要處理的URL 27 | for (msg_idx, message) in messages.iter().enumerate() { 28 | if let OpenAiContent::Multi(items) = &message.content { 29 | for (item_idx, item) in items.iter().enumerate() { 30 | if let OpenAiContentItem::ImageUrl { image_url } = item { 31 | if image_url.url.starts_with("data:") { 32 | // 處理data URL 33 | debug!("🔍 發現data URL"); 34 | data_urls.push(image_url.url.clone()); 35 | data_url_indices.push((msg_idx, item_idx)); 36 | } else if !is_poe_cdn_url(&image_url.url) { 37 | // 處理需要上傳的外部URL 38 | debug!("🔍 發現需要上傳的外部URL: {}", image_url.url); 39 | external_urls.push(image_url.url.clone()); 40 | url_indices.push((msg_idx, item_idx)); 41 | } 42 | } 43 | } 44 | } 45 | } 46 | 47 | // 處理外部URL 48 | if !external_urls.is_empty() { 49 | debug!("🔄 準備處理 {} 個外部URL", external_urls.len()); 50 | 51 | // 將外部URL分為緩存命中和未命中兩組 52 | let mut urls_to_upload = Vec::new(); 53 | let mut urls_indices_to_upload = Vec::new(); 54 | 55 | for (idx, (msg_idx, item_idx)) in url_indices.iter().enumerate() { 56 | let url = &external_urls[idx]; 57 | 58 | // 檢查緩存 59 | if let Some((poe_url, _)) = crate::cache::get_cached_url(url) { 60 | debug!("✅ URL緩存命中: {} -> {}", url, poe_url); 61 | 62 | if let OpenAiContent::Multi(items) = &mut messages[*msg_idx].content { 63 | if let OpenAiContentItem::ImageUrl { image_url } = &mut items[*item_idx] { 64 | debug!("🔄 從緩存替換URL: {}", poe_url); 65 | image_url.url = poe_url; 66 | } 67 | } 68 | } else { 69 | // 緩存未命中,需要上傳 70 | debug!("❌ URL緩存未命中: {}", url); 71 | urls_to_upload.push(url.clone()); 72 | urls_indices_to_upload.push((*msg_idx, *item_idx)); 73 | } 74 | } 75 | 76 | // 上傳未緩存的URL 77 | if !urls_to_upload.is_empty() { 78 | debug!("🔄 上傳 {} 個未緩存的URL", urls_to_upload.len()); 79 | 80 | let upload_requests: Vec = urls_to_upload 81 | .iter() 82 | .map(|url| FileUploadRequest::RemoteFile { 83 | download_url: url.clone(), 84 | }) 85 | .collect(); 86 | 87 | match poe_client.client.upload_files_batch(upload_requests).await { 88 | Ok(responses) => { 89 | debug!("✅ 成功上傳 {} 個外部URL", responses.len()); 90 | 91 | // 更新緩存並保存URL映射 92 | for (idx, ((msg_idx, item_idx), response)) in urls_indices_to_upload 93 | .iter() 94 | .zip(responses.iter()) 95 | .enumerate() 96 | { 97 | let original_url = &urls_to_upload[idx]; 98 | 99 | // 估算大小 (默認1MB,實際使用中可以優化) 100 | let size_bytes = 1024 * 1024; 101 | 102 | // 添加到緩存 103 | crate::cache::cache_url(original_url, &response.attachment_url, size_bytes); 104 | 105 | if let OpenAiContent::Multi(items) = &mut messages[*msg_idx].content { 106 | if let OpenAiContentItem::ImageUrl { image_url } = &mut items[*item_idx] 107 | { 108 | debug!( 109 | "🔄 替換URL | 原始: {} | Poe: {}", 110 | image_url.url, response.attachment_url 111 | ); 112 | image_url.url = response.attachment_url.clone(); 113 | } 114 | } 115 | } 116 | } 117 | Err(e) => { 118 | error!("❌ 上傳外部URL失敗: {}", e); 119 | return Err(Box::new(std::io::Error::new( 120 | std::io::ErrorKind::Other, 121 | format!("上傳外部URL失敗: {}", e), 122 | ))); 123 | } 124 | } 125 | } 126 | } 127 | 128 | // 處理data URL 129 | if !data_urls.is_empty() { 130 | debug!("🔄 準備處理 {} 個data URL", data_urls.len()); 131 | 132 | // 分為緩存命中和未命中兩組 133 | let mut data_to_upload = Vec::new(); 134 | let mut data_indices_to_upload = Vec::new(); 135 | let mut data_hashes = Vec::new(); 136 | 137 | for (idx, (msg_idx, item_idx)) in data_url_indices.iter().enumerate() { 138 | let data_url = &data_urls[idx]; 139 | let hash = hash_base64_content(data_url); 140 | 141 | debug!("🔍 計算data URL哈希值 | 哈希頭部: {}...", &hash[..8]); 142 | 143 | // 檢查緩存 144 | if let Some((poe_url, _)) = crate::cache::get_cached_base64(&hash) { 145 | debug!("✅ base64緩存命中 | 哈希: {}... -> {}", &hash[..8], poe_url); 146 | 147 | if let OpenAiContent::Multi(items) = &mut messages[*msg_idx].content { 148 | if let OpenAiContentItem::ImageUrl { image_url } = &mut items[*item_idx] { 149 | debug!("🔄 從緩存替換base64 | URL: {}", poe_url); 150 | image_url.url = poe_url; 151 | } 152 | } 153 | } else { 154 | // 緩存未命中,需要上傳 155 | debug!("❌ base64緩存未命中 | 哈希: {}...", &hash[..8]); 156 | data_to_upload.push(data_url.clone()); 157 | data_indices_to_upload.push((idx, (*msg_idx, *item_idx))); 158 | data_hashes.push(hash); 159 | } 160 | } 161 | 162 | // 上傳未緩存的data URL 163 | if !data_to_upload.is_empty() { 164 | let mut upload_requests = Vec::new(); 165 | 166 | // 將data URL轉換為臨時文件 167 | for data_url in data_to_upload.iter() { 168 | // 從 data URL 中提取 MIME 類型 169 | let mime_type = if data_url.starts_with("data:") { 170 | let parts: Vec<&str> = data_url.split(";base64,").collect(); 171 | if !parts.is_empty() { 172 | let mime_part = parts[0].trim_start_matches("data:"); 173 | debug!("🔍 提取的 MIME 類型: {}", mime_part); 174 | Some(mime_part.to_string()) 175 | } else { 176 | None 177 | } 178 | } else { 179 | None 180 | }; 181 | 182 | match handle_data_url_to_temp_file(data_url) { 183 | Ok(file_path) => { 184 | debug!("📄 創建臨時文件成功: {}", file_path.display()); 185 | upload_requests.push(FileUploadRequest::LocalFile { 186 | file: file_path.to_string_lossy().to_string(), 187 | mime_type, 188 | }); 189 | temp_files.push(file_path); 190 | } 191 | Err(e) => { 192 | error!("❌ 處理data URL失敗: {}", e); 193 | // 清理已創建的臨時文件 194 | for path in &temp_files { 195 | if let Err(e) = fs::remove_file(path) { 196 | warn!("⚠️ 無法刪除臨時文件 {}: {}", path.display(), e); 197 | } 198 | } 199 | return Err(Box::new(std::io::Error::new( 200 | std::io::ErrorKind::InvalidData, 201 | format!("處理data URL失敗: {}", e), 202 | ))); 203 | } 204 | } 205 | } 206 | 207 | // 上傳臨時文件 208 | if !upload_requests.is_empty() { 209 | match poe_client.client.upload_files_batch(upload_requests).await { 210 | Ok(responses) => { 211 | debug!("✅ 成功上傳 {} 個臨時文件", responses.len()); 212 | 213 | // 更新緩存並保存URL映射 214 | for (idx, response) in responses.iter().enumerate() { 215 | let (_, (msg_idx, item_idx)) = data_indices_to_upload[idx]; 216 | let hash = &data_hashes[idx]; 217 | let data_url = &data_to_upload[idx]; 218 | 219 | // 估算大小 220 | let size = crate::cache::estimate_base64_size(data_url); 221 | 222 | // 添加到緩存 223 | crate::cache::cache_base64(hash, &response.attachment_url, size); 224 | 225 | debug!( 226 | "🔄 將base64哈希映射到Poe URL | 哈希: {}... -> {}", 227 | &hash[..8], 228 | response.attachment_url 229 | ); 230 | 231 | if let OpenAiContent::Multi(items) = &mut messages[msg_idx].content { 232 | if let OpenAiContentItem::ImageUrl { image_url } = 233 | &mut items[item_idx] 234 | { 235 | debug!("🔄 替換data URL | Poe: {}", response.attachment_url); 236 | image_url.url = response.attachment_url.clone(); 237 | } 238 | } 239 | } 240 | } 241 | Err(e) => { 242 | error!("❌ 上傳臨時文件失敗: {}", e); 243 | // 清理臨時文件 244 | for path in &temp_files { 245 | if let Err(e) = fs::remove_file(path) { 246 | warn!("⚠️ 無法刪除臨時文件 {}: {}", path.display(), e); 247 | } 248 | } 249 | return Err(Box::new(std::io::Error::new( 250 | std::io::ErrorKind::Other, 251 | format!("上傳臨時文件失敗: {}", e), 252 | ))); 253 | } 254 | } 255 | } 256 | 257 | // 清理臨時文件 258 | for path in &temp_files { 259 | if let Err(e) = fs::remove_file(path) { 260 | warn!("⚠️ 無法刪除臨時文件 {}: {}", path.display(), e); 261 | } else { 262 | debug!("🗑️ 已刪除臨時文件: {}", path.display()); 263 | } 264 | } 265 | } 266 | } 267 | 268 | // 處理AI回覆中的Poe CDN連結,將其添加到用戶消息的image_url中 269 | if messages.len() >= 2 { 270 | // 尋找最後一個AI回覆和用戶消息 271 | let last_bot_idx = messages 272 | .iter() 273 | .enumerate() 274 | .filter(|(_, msg)| msg.role == "assistant") 275 | .last() 276 | .map(|(i, _)| i); 277 | let last_user_idx = messages 278 | .iter() 279 | .enumerate() 280 | .filter(|(_, msg)| msg.role == "user") 281 | .last() 282 | .map(|(i, _)| i); 283 | 284 | if let (Some(bot_idx), Some(user_idx)) = (last_bot_idx, last_user_idx) { 285 | // 提取AI回覆中的Poe CDN連結 286 | let poe_cdn_urls = extract_poe_cdn_urls_from_message(&messages[bot_idx]); 287 | if !poe_cdn_urls.is_empty() { 288 | debug!( 289 | "🔄 從AI回覆中提取了 {} 個Poe CDN連結,添加到用戶消息", 290 | poe_cdn_urls.len() 291 | ); 292 | // 將這些連結添加到用戶消息的image_url中 293 | let user_msg = &mut messages[user_idx]; 294 | match &mut user_msg.content { 295 | OpenAiContent::Text(text) => { 296 | // 將文本消息轉換為多部分消息,加入圖片 297 | let mut items = Vec::new(); 298 | items.push(OpenAiContentItem::Text { text: text.clone() }); 299 | for url in poe_cdn_urls { 300 | items.push(OpenAiContentItem::ImageUrl { 301 | image_url: ImageUrlContent { url }, 302 | }); 303 | } 304 | user_msg.content = OpenAiContent::Multi(items); 305 | } 306 | OpenAiContent::Multi(items) => { 307 | // 已經是多部分消息,直接添加圖片 308 | for url in poe_cdn_urls { 309 | items.push(OpenAiContentItem::ImageUrl { 310 | image_url: ImageUrlContent { url }, 311 | }); 312 | } 313 | } 314 | } 315 | } 316 | } 317 | } 318 | 319 | Ok(()) 320 | } 321 | 322 | // 從 OpenAIContent 獲取純文本內容 323 | pub fn get_text_from_openai_content(content: &OpenAiContent) -> String { 324 | match content { 325 | OpenAiContent::Text(s) => s.clone(), 326 | OpenAiContent::Multi(items) => { 327 | let mut text_parts = Vec::new(); 328 | for item in items { 329 | if let OpenAiContentItem::Text { text } = item { 330 | // 使用 serde_json::to_string 處理文本中的特殊字符 331 | match serde_json::to_string(text) { 332 | Ok(processed_text) => { 333 | // 移除 serde_json::to_string 添加的開頭和結尾的引號 334 | let processed_text = processed_text.trim_matches('"').to_string(); 335 | // 將 JSON 轉義的引號 (\") 替換為普通引號 (") 336 | let processed_text = processed_text.replace("\\\"", "\""); 337 | text_parts.push(processed_text); 338 | } 339 | Err(_) => { 340 | // 如果序列化失敗,使用原始文本 341 | text_parts.push(text.clone()); 342 | } 343 | } 344 | } 345 | } 346 | text_parts.join("\n") 347 | } 348 | } 349 | } 350 | 351 | // 檢查URL是否為Poe CDN連結 352 | pub fn is_poe_cdn_url(url: &str) -> bool { 353 | url.starts_with("https://pfst.cf2.poecdn.net") 354 | } 355 | 356 | // 從消息中提取Poe CDN連結 357 | pub fn extract_poe_cdn_urls_from_message(message: &Message) -> Vec { 358 | let mut urls = Vec::new(); 359 | match &message.content { 360 | OpenAiContent::Multi(items) => { 361 | for item in items { 362 | if let OpenAiContentItem::ImageUrl { image_url } = item { 363 | if is_poe_cdn_url(&image_url.url) { 364 | urls.push(image_url.url.clone()); 365 | } 366 | } else if let OpenAiContentItem::Text { text } = item { 367 | // 從文本中提取 Poe CDN URL 368 | extract_urls_from_markdown(text, &mut urls); 369 | } 370 | } 371 | } 372 | OpenAiContent::Text(text) => { 373 | // 從純文本消息中提取 Poe CDN URL 374 | extract_urls_from_markdown(text, &mut urls); 375 | } 376 | } 377 | urls 378 | } 379 | 380 | // 從 Markdown 文本中提取 Poe CDN URL 的輔助函數 381 | fn extract_urls_from_markdown(text: &str, urls: &mut Vec) { 382 | // 提取 Markdown 圖片格式的 URL: ![alt](url) 383 | let re_md_img = regex::Regex::new(r"!\[.*?\]\((https?://[^\s)]+)\)").unwrap(); 384 | for cap in re_md_img.captures_iter(text) { 385 | if let Some(url) = cap.get(1) { 386 | let url_str = url.as_str(); 387 | if is_poe_cdn_url(url_str) { 388 | urls.push(url_str.to_string()); 389 | } 390 | } 391 | } 392 | // 同時處理直接出現的 URL 393 | for word in text.split_whitespace() { 394 | if is_poe_cdn_url(word) { 395 | urls.push(word.to_string()); 396 | } 397 | } 398 | } 399 | 400 | // 處理base64數據URL,將其存儲為臨時文件 401 | pub fn handle_data_url_to_temp_file(data_url: &str) -> Result { 402 | // 1. 驗證資料 URL 格式 403 | if !data_url.starts_with("data:") { 404 | return Err("無效的資料 URL 格式".to_string()); 405 | } 406 | // 2. 分離 MIME 類型和 base64 資料 407 | let parts: Vec<&str> = data_url.split(";base64,").collect(); 408 | if parts.len() != 2 { 409 | return Err("無效的資料 URL 格式:缺少 base64 分隔符".to_string()); 410 | } 411 | // 3. 提取 MIME 類型 412 | let mime_type = parts[0].strip_prefix("data:").unwrap_or(parts[0]); 413 | debug!("🔍 提取的 MIME 類型: {}", mime_type); 414 | // 4. 根據 MIME 類型決定檔案擴充名 415 | let file_ext = mime_type_to_extension(mime_type).unwrap_or("bin"); 416 | debug!("📄 使用檔案擴充名: {}", file_ext); 417 | // 5. 解碼 base64 資料 (僅使用 BASE64_STANDARD) 418 | let base64_data = parts[1]; 419 | debug!("🔢 Base64 資料長度: {}", base64_data.len()); 420 | let decoded = match BASE64_STANDARD.decode(base64_data) { 421 | Ok(data) => { 422 | debug!("✅ Base64 解碼成功 | 資料大小: {} 位元組", data.len()); 423 | data 424 | } 425 | Err(e) => { 426 | error!("❌ Base64 解碼失敗: {}", e); 427 | return Err(format!("Base64 解碼失敗: {}", e)); 428 | } 429 | }; 430 | // 6. 建立臨時檔案 431 | let temp_dir = std::env::temp_dir(); 432 | let file_name = format!("poe2openai_{}.{}", nanoid!(16), file_ext); 433 | let file_path = temp_dir.join(&file_name); 434 | // 7. 寫入資料到臨時檔案 435 | match fs::write(&file_path, &decoded) { 436 | Ok(_) => { 437 | debug!("✅ 成功寫入臨時檔案: {}", file_path.display()); 438 | Ok(file_path) 439 | } 440 | Err(e) => { 441 | error!("❌ 寫入臨時檔案失敗: {}", e); 442 | Err(format!("寫入臨時檔案失敗: {}", e)) 443 | } 444 | } 445 | } 446 | 447 | // 從MIME類型獲取文件擴展名 448 | fn mime_type_to_extension(mime_type: &str) -> Option<&str> { 449 | match mime_type { 450 | "image/jpeg" | "image/jpg" => Some("jpeg"), 451 | "image/png" => Some("png"), 452 | "image/gif" => Some("gif"), 453 | "image/webp" => Some("webp"), 454 | "image/svg+xml" => Some("svg"), 455 | "image/bmp" => Some("bmp"), 456 | "image/tiff" => Some("tiff"), 457 | "application/pdf" => Some("pdf"), 458 | "text/plain" => Some("txt"), 459 | "text/csv" => Some("csv"), 460 | "application/json" => Some("json"), 461 | "application/xml" | "text/xml" => Some("xml"), 462 | "application/zip" => Some("zip"), 463 | "application/x-tar" => Some("tar"), 464 | "application/x-gzip" => Some("gz"), 465 | "audio/mpeg" => Some("mp3"), 466 | "audio/wav" => Some("wav"), 467 | "audio/ogg" => Some("ogg"), 468 | "video/mp4" => Some("mp4"), 469 | "video/mpeg" => Some("mpeg"), 470 | "video/quicktime" => Some("mov"), 471 | _ => None, 472 | } 473 | } 474 | 475 | pub fn convert_poe_error_to_openai( 476 | error_text: &str, 477 | allow_retry: bool, 478 | ) -> (StatusCode, OpenAIErrorResponse) { 479 | debug!( 480 | "🔄 轉換錯誤響應 | 錯誤文本: {}, 允許重試: {}", 481 | error_text, allow_retry 482 | ); 483 | let (status, error_type, code) = if error_text.contains("Internal server error") { 484 | ( 485 | StatusCode::INTERNAL_SERVER_ERROR, 486 | "internal_error", 487 | "internal_error", 488 | ) 489 | } else if error_text.contains("rate limit") { 490 | ( 491 | StatusCode::TOO_MANY_REQUESTS, 492 | "rate_limit_exceeded", 493 | "rate_limit_exceeded", 494 | ) 495 | } else if error_text.contains("Invalid token") || error_text.contains("Unauthorized") { 496 | (StatusCode::UNAUTHORIZED, "invalid_auth", "invalid_api_key") 497 | } else if error_text.contains("Bot does not exist") { 498 | (StatusCode::NOT_FOUND, "model_not_found", "model_not_found") 499 | } else { 500 | (StatusCode::BAD_REQUEST, "invalid_request", "bad_request") 501 | }; 502 | debug!( 503 | "📋 錯誤轉換結果 | 狀態碼: {} | 錯誤類型: {}", 504 | status.as_u16(), 505 | error_type 506 | ); 507 | ( 508 | status, 509 | OpenAIErrorResponse { 510 | error: OpenAIError { 511 | message: error_text.to_string(), 512 | r#type: error_type.to_string(), 513 | code: code.to_string(), 514 | param: None, 515 | }, 516 | }, 517 | ) 518 | } 519 | 520 | pub fn format_bytes_length(bytes: usize) -> String { 521 | if bytes < 1024 { 522 | format!("{} B", bytes) 523 | } else if bytes < 1024 * 1024 { 524 | format!("{:.2} KB", bytes as f64 / 1024.0) 525 | } else { 526 | format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0)) 527 | } 528 | } 529 | 530 | pub fn format_duration(duration: std::time::Duration) -> String { 531 | if duration.as_secs() > 0 { 532 | format!("{:.2}s", duration.as_secs_f64()) 533 | } else { 534 | format!("{}ms", duration.as_millis()) 535 | } 536 | } 537 | 538 | pub fn get_config_path(filename: &str) -> PathBuf { 539 | let config_dir = std::env::var("CONFIG_DIR").unwrap_or_else(|_| "./".to_string()); 540 | let mut path = PathBuf::from(config_dir); 541 | path.push(filename); 542 | path 543 | } 544 | 545 | pub fn load_config_from_yaml() -> Result { 546 | let path_str = "models.yaml"; 547 | let path = get_config_path(path_str); 548 | if path.exists() { 549 | match std::fs::read_to_string(path) { 550 | Ok(contents) => match serde_yaml::from_str::(&contents) { 551 | Ok(config) => { 552 | info!("✅ 成功讀取並解析 {}", path_str); 553 | Ok(config) 554 | } 555 | Err(e) => { 556 | error!("❌ 解析 {} 失敗: {}", path_str, e); 557 | Err(format!("解析 {} 失敗: {}", path_str, e)) 558 | } 559 | }, 560 | Err(e) => { 561 | error!("❌ 讀取 {} 失敗: {}", path_str, e); 562 | Err(format!("讀取 {} 失敗: {}", path_str, e)) 563 | } 564 | } 565 | } else { 566 | debug!("⚠️ {} 不存在,使用預設空配置", path_str); 567 | // 返回一個預設的 Config,表示文件不存在或無法讀取 568 | Ok(Config { 569 | enable: Some(false), 570 | models: std::collections::HashMap::new(), 571 | custom_models: None, 572 | }) 573 | } 574 | } 575 | 576 | /// 計算文本的 token 數量 577 | pub fn count_tokens(text: &str) -> u32 { 578 | let bpe = match o200k_base() { 579 | Ok(bpe) => bpe, 580 | Err(e) => { 581 | error!("❌ 無法初始化 BPE 編碼器: {}", e); 582 | return 0; 583 | } 584 | }; 585 | let tokens = bpe.encode_with_special_tokens(text); 586 | tokens.len() as u32 587 | } 588 | 589 | /// 計算消息列表的 token 數量 590 | pub fn count_message_tokens(messages: &[Message]) -> u32 { 591 | let mut total_tokens = 0; 592 | for message in messages { 593 | // 每條消息的基本 token 數(角色標記等) 594 | total_tokens += 4; // 每條消息的基本開銷 595 | // 計算內容的 token 數 596 | let content_text = get_text_from_openai_content(&message.content); 597 | total_tokens += count_tokens(&content_text); 598 | } 599 | // 添加消息格式的額外 token 600 | total_tokens += 2; // 消息格式的開始和結束標記 601 | total_tokens 602 | } 603 | 604 | /// 計算完成內容的 token 數量 605 | pub fn count_completion_tokens(completion: &str) -> u32 { 606 | count_tokens(completion) 607 | } 608 | 609 | /// 計算 base64 字符串的 SHA256 哈希 610 | pub fn hash_base64_content(base64_str: &str) -> String { 611 | // 提取純base64部分,去除MIME類型前綴 612 | let base64_data = match base64_str.split(";base64,").nth(1) { 613 | Some(data) => data, 614 | None => base64_str, // 如果沒有分隔符,使用整個字符串 615 | }; 616 | 617 | let start = &base64_data[..base64_data.len().min(1024)]; 618 | let end = if base64_data.len() > 2048 { 619 | // 確保有足夠長度 620 | &base64_data[base64_data.len() - 1024..] 621 | } else if base64_data.len() > 1024 { 622 | &base64_data[1024..] // 如果長度在1024-2048之間,使用剩餘部分 623 | } else { 624 | "" // 如果小於1024,只使用start 625 | }; 626 | 627 | // 結合頭部和尾部數據 628 | let combined = format!("{}{}", start, end); 629 | 630 | // 計算SHA256哈希 631 | let mut hasher = Sha256::new(); 632 | hasher.update(combined.as_bytes()); 633 | let result = hasher.finalize(); 634 | 635 | // 記錄哈希計算信息以便調試 636 | let hash = format!("{:x}", result); 637 | debug!( 638 | "🔢 計算base64哈希 | 數據長度: {} | 計算長度: {} | 哈希值頭部: {}...", 639 | base64_data.len(), 640 | start.len() + end.len(), 641 | &hash[..8] 642 | ); 643 | 644 | hash 645 | } 646 | -------------------------------------------------------------------------------- /static/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeromeleong/poe2openai/0d4658fdc7c6acd6128fa0269d465fef26fd4ca0/static/fa-solid-900.ttf -------------------------------------------------------------------------------- /static/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeromeleong/poe2openai/0d4658fdc7c6acd6128fa0269d465fef26fd4ca0/static/fa-solid-900.woff2 -------------------------------------------------------------------------------- /templates/admin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Models 管理介面 7 | 8 | 9 | 40 | 41 | 42 |
43 | 44 |
45 |
46 |

Models 管理介面

47 |
48 | 49 | 57 | 58 |
59 | 啟用自定義 60 | 64 |
65 | 66 |
67 | 隱藏停用模型 68 | 72 |
73 |
74 |
75 | 76 |
77 | 81 | 85 | 89 | 93 | 97 |
98 |
99 | 100 | 101 |
102 |
103 |
104 |
105 | 106 | 107 |
108 |
109 |
110 | 117 |
118 |
119 |
120 | 121 |
122 |
123 |
124 | 總模型數 125 | 0 126 |
127 |
128 | 自訂模型 129 | 0 130 |
131 |
132 | 已映射 133 | 0 134 |
135 |
136 | 替換回應 137 | 0 138 |
139 |
140 | 已停用 141 | 0 142 |
143 |
144 |
145 | 146 |
147 |
148 |
149 | 150 |

載入模型中...

151 |
152 |
153 |
154 | 155 |
156 | 160 | 167 |
168 | 169 | 170 | 173 |
174 | 175 |
176 |
177 |
178 |

編輯Model映射名稱

179 | 182 |
183 | 184 |
185 | 188 | 191 |
192 |
193 |
194 | 195 |
196 |
197 |
198 |

添加自訂模型

199 | 202 |
203 |
204 |
205 | 206 | 207 |
208 |
209 | 210 | 211 |
212 |
213 |
214 | 217 | 220 |
221 |
222 |
223 | 224 |
225 |
226 |
227 |

自訂模型管理

228 | 231 |
232 |
233 | 234 |
235 | 236 |

尚未添加自訂模型

237 |
238 |
239 |
240 | 243 |
244 |
245 |
246 | 247 |
248 |
249 |
250 |

功能說明

251 | 254 |
255 |
256 |
257 |

模型狀態設定

258 |
    259 |
  • 260 | 無標記 (-): 261 | 保持原始模型設定,不做任何修改 262 |
  • 263 |
  • 264 | 黃色 (R): 265 | 主要為在Poe平台的文生圖/文生視頻相關模型作出的兼容性處理 266 |
  • 267 |
  • 268 | 打叉 (X): 269 | 停用該模型,在模型列表中將不會顯示此模型 270 |
  • 271 |
272 |
273 |
274 |

模型映射功能

275 |
    276 |
  • 277 | 可為模型設定一個新的顯示名稱,原始名稱會顯示在左側並加上刪除線 278 |
  • 279 |
  • 280 | 適用於需要將模型名稱對應到其他API格式的情況 281 |
  • 282 |
  • 283 | 映射後的名稱將會在所有API端點中使用 284 |
  • 285 |
286 |
287 |
288 |

全域啟用開關影響範圍

289 |
    290 |
  • 291 | 啟用後會影響以下API端點的行為: 292 |
  • 293 |
  • 294 | 模型列表過濾 & 模型映射 295 |
  • 296 |
  • 297 | - GET /v1/models 298 |
  • 299 |
  • 300 | - GET /models 301 |
  • 302 |
  • 303 | 聊天事件處理 & 模型映射 304 |
  • 305 |
  • 306 | - POST /chat/completions 307 |
  • 308 |
  • 309 | - POST /v1/chat/completions 310 |
  • 311 |
312 |
313 |
314 |

自訂模型功能

315 |
    316 |
  • 317 | 可在模型列表中添加自訂模型 318 |
  • 319 |
  • 320 | 直接填寫Bot名稱即可添加 321 |
  • 322 |
  • 323 | 可選填模型提供商資訊 324 |
  • 325 |
  • 326 | 自訂模型以綠色邊框標記 327 |
  • 328 |
329 |
330 |
331 |

搜索與過濾

332 |
    333 |
  • 334 | 使用搜索框快速尋找特定模型 335 |
  • 336 |
  • 337 | 可按模型類型進行過濾:全部、自訂、已映射、替換回應或已停用 338 |
  • 339 |
  • 340 | 可透過切換隱藏停用模型,提高界面簡潔度 341 |
  • 342 |
  • 343 | 界面設置會自動保存在本地 344 |
  • 345 |
346 |
347 |
348 |
349 |
350 | 351 |
352 | 353 |
354 | 1079 | 1080 | --------------------------------------------------------------------------------