# PyroDash **Repository Path**: cm_min/pyro-dash ## Basic Information - **Project Name**: PyroDash - **Description**: No description available - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: feat/v0.1-gateway - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-18 - **Last Updated**: 2026-09-18 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # PyroDash 私有化部署的模型网关平台:本地小模型自主决定何时把推理交接给外部大模型,同时把每一次调用的 token 用量、缓存命中与金额完整记录下来。 ## 能力 - **协同推理(offload)**:本机 llama.cpp 输出 `<|llm_offload|>N` 控制 token 时中断本地生成, 把 `{原始 query + 部分推理}` 交接给外部大模型续写,客户端感知不到切换。 - **静默降级**:外部不可用时自动续读本地流,不中断服务。 - **用量监控**:输入 / 输出 / 缓存命中 / 缓存写入四类 token 分开计量,并支持按 Provider、 模型、日期、协议四个维度聚合。 - **金额监控**:能查余额的厂商(如 DeepSeek `/user/balance`)展示厂商真值;其余厂商由本地 用量账本推算已用金额,并在接口中标记 `source` 以区分真值与推算。 - **灵活配置**:Provider 通过管理 API 增删改查,**改动即时生效**,无需重启;API Key 支持 `env:VAR_NAME` 间接引用,密钥不落库。 - **offload 开箱即用**:控制 token 是模型的**行为约定**而非固有知识,客户端提示词里没有它, 小模型永远不会主动求助。网关会**只对本地请求**补上这条约定(客户端已自带则跳过), 给外部大模型的请求不含它。可用 `PyroDash:LocalOffloadHintEnabled=false` 关闭。 - **三套入站协议**:`/v1/chat/completions`(标准)、`/v1/messages`(Claude 专用)、 `/v1/responses`(Codex 专用)。 - **工具调用(function calling)**:三套协议都支持端到端透传——客户端的 `tools` 与工具结果 原样转发给上游,上游的 `tool_calls` 逐帧回传,结束原因落成 `tool_calls` / `tool_use`。 依赖函数调用的 agent(Claude Code / Codex / Cline 等)可经网关工作。 - **多模态**:文本 + 图片(`image/png`、`image/jpeg`、`image/webp`、`image/gif`)。 只记录图片张数与字节数,**图片内容不落库**;远程图片 URL 默认拒绝(SSRF 防护)。 - **跨平台 AOT**:单个自包含可执行文件,覆盖 win-x64 / linux-x64 / linux-arm64 / osx-arm64, 监控面板已内嵌其中。 ## 快速开始 ```bash # 1. 构建(前端产物会被内嵌进二进制) export PATH="$HOME/.dotnet:$PATH" cd src/PyroDash.Web && npm ci && npm run build && cd ../.. ./scripts/publish-aot.sh linux-x64 # 2. 启动(无需任何环境变量,配置全部读自同目录的 appsettings.json) cd publish/linux-x64 ./pyrodash --urls http://127.0.0.1:8080 ``` **首次启动会自动生成两枚随机令牌并写回配置文件**,日志里会打印(这是唯一一次打印它们): ``` 已生成管理令牌并写入 /opt/pyrodash/appsettings.json:<32 位随机令牌> 已生成网关密钥并写入 /opt/pyrodash/appsettings.json:<32 位随机密钥> 管理令牌来源:本次生成 网关密钥来源:本次生成 ``` 从第二次启动起,凭据直接读自配置文件,日志只报来源、不再打印值: ``` 管理令牌来源:配置文件 网关密钥来源:配置文件 ``` 把这两枚值填进客户端即可: - 管理面板 :右上角「管理令牌」填 `PyroDash.AdminToken` - 推理接口 `/v1/**`:`Authorization: Bearer ` 发布目录不是字面单文件:`pyrodash` 旁边还有 `libe_sqlite3.so`(Windows 为 `e_sqlite3.dll`) 与 `appsettings.json`,迁移时请整体拷贝(**令牌就在 appsettings.json 里**,一起拷走才不会换新)。 ## 配置 ### 配置文件优先(严格) `appsettings.json` 的 `PyroDash` 段是**随包分发、运维直接编辑**的配置载体。**文件里写了什么就跑什么**: 只要某个键写了一个具体值,它就是这个进程的运行值,环境变量不再有发言权—— 旧 shell 里残留的、镜像里写死的、CI 注入的 `PYRODASH_*`,不可能再静默改掉运维写下的配置。 每个键都出现在文件里,值写 `null` 表示**未指定**:回退到环境变量,再回退到代码默认值。 **一旦在文件里写了具体值,就只有编辑文件才能改变它。** 优先级(从高到低): | 顺序 | 来源 | 生效条件 | | --- | --- | --- | | 1 | `appsettings.json` 的 `PyroDash:*` | 键存在且不是「未指定」——**显式值即最终值** | | 2 | `PYRODASH_*` 环境变量 | 文件里该键未指定时(容器 / CI / 只读卷的注入手段) | | 3 | 旧版扁平键(`DatabasePath`、`MaxImagesPerRequest` …) | 仅为兼容历史写法保留 | | 4 | 代码默认值 | 兜底 | 「未指定」的判定(对所有类型一致):**键缺失、JSON `null`、字符串为空或全空白、数组为 `null` 或 `[]`**。 其余都算显式值——特别是**布尔 `false` 与数字 `0` 是合法且必须生效的值**,不会被环境变量顶掉。 ```jsonc { "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "PyroDash": { "DatabasePath": null, // null = 未指定 → 用 PYRODASH_DB,再不然用代码默认值 pyrodash.db "LocalBaseUrl": null, "LocalModelId": null, "LocalVisionEnabled": null, // 写成 false 就成了显式值:PYRODASH_LOCAL_VISION=true 不再生效 "AdminToken": null, // 未指定 → 首次启动生成并写回,此后文件里的真值压过环境变量 "GatewayKeys": null, // null 或 [] 都算未指定 → 首次启动生成一枚 "BalancePollSeconds": null, "DefaultMaxTokens": null, "UpstreamTimeoutSeconds": null, "Media": { "MaxImagesPerRequest": null, "MaxImageBytes": null, "AllowRemoteImageUrls": null } } } ``` 同一份环境变量(`PYRODASH_LOCAL_VISION=true`)下,文件里三种写法的结果: | 文件里的写法 | 结果 | | --- | --- | | `"LocalVisionEnabled": null` | 环境变量生效 → 本地视觉**开启**(容器 / CI 正是靠这个注入) | | 键不存在 | 同 `null`:未指定 | | `"LocalVisionEnabled": false` | 显式关闭,环境变量被忽略——写什么跑什么 | ### 首次启动自动供给(为什么不是写死一个默认令牌) 仓库推送远端,**写死令牌会进 git 历史并被所有部署共用**,等于全网一个密码。 因此文件里把这两个键留成未指定(`null` / `""` / `[]`)时,首次启动现场生成令牌并写回 **应用实际加载的那个** `appsettings.json`: - 令牌是 24 字节密码学随机数(`RandomNumberGenerator`)的 base64,32 字符、URL 安全 - 只改这两个字段,文件里其余内容原样保留 - 写回之后文件里就是**真值**,于是从下一次启动起它压过环境变量里的任何值(严格文件优先) - 写不进去(只读卷、权限不足)**不阻断启动**:把值打进日志并明确提示「未被持久化」, 运维照抄即可,绝不会因为文件只读就把人锁在门外 - 幂等:配置文件已填好的第二次启动既不重新生成也不重写文件(连 mtime 都不变) ### 逃生开关 ```bash PYRODASH_DISABLE_TOKEN_PROVISIONING=true ``` 置真后完全不生成、不写任何文件,适合自管凭据的运维与需要密闭环境的测试。 ### 安全语义(与空值有关,务必看清) 「空」在**取值**上是「未指定」(回退到环境变量 / 代码默认值),在**运行语义**上是: - `AdminToken` 为空 → **管理接口一律 401** - `GatewayKeys` 为空(`[]`)→ **`/v1` 放行所有请求**(等同 allow all),只适合本机开发。 启动时会为此打印显式告警。 ## 客户端接入 | 客户端 | Base URL | 鉴权 | | --- | --- | --- | | OpenAI SDK / 任意兼容客户端 | `http://127.0.0.1:8080/v1` | `Authorization: Bearer ` | | Claude Code / Anthropic SDK | `http://127.0.0.1:8080` | `x-api-key: ` | | Codex CLI | `http://127.0.0.1:8080/v1` | `Authorization: Bearer ` | 逐客户端的完整填法(Claude Code 环境变量、Codex `config.toml`、GUI 客户端字段、curl 自测, 以及本机实测的坑——例如这是推理模型、`max_tokens` 给少了会被思考过程吃光)见 `docs/handoff/客户端接入与手动测试.md`;本机一键拉起「网关 + 本地小模型」的脚本是 `scripts/start-local.ps1`(模型与转换见 `docs/handoff/本地模型-GGUF转换与运行.md`)。 官方开源的三个 checkpoint 都已转成 **f16 GGUF(不做任何量化)**,`run.ps1` 默认把 `models\` 里 找到的全部 checkpoint **同时**提供出来:单个 llama-server 以 router 模式运行, 客户端用请求里的 `model` 点名(冷切换,按需加载、同时只驻留一个),**不需要重启任何东西**。 | `model` 取值 | 定位 | | --- | --- | | `PyroDash-4B-SFT` | 冷启动 SFT 版(默认) | | `PyroDash-4B-GRPO-Lambda-0.05` | GRPO 强化,偏省成本 | | `PyroDash-4B-GRPO-Lambda-0.6` | GRPO 强化,偏准确度 | 下载、转换、指纹校验与加载验证脚本都在 `scripts/gguf/`(`models/` 只放权重,不入库)。 客户端发别的模型名(`claude-sonnet-4-5`、`gpt-5-codex` 等)会回落到默认 checkpoint,不会 404。 ## 配置项 下表是 `PyroDash.*` 配置键、等价的环境变量与代码默认值。 环境变量**只在文件里该键为 `null`(未指定)时生效**;在文件里写具体值即可锁死该键。 | 配置键(`appsettings.json`) | 环境变量(文件里写 `null` 时生效) | 代码默认值 | 说明 | | --- | --- | --- | --- | | `PyroDash:DatabasePath` | `PYRODASH_DB` | `pyrodash.db` | SQLite 数据库路径 | | `PyroDash:LocalBaseUrl` | `PYRODASH_LOCAL_URL` | `http://127.0.0.1:8081` | 本机 llama.cpp 端点 | | `PyroDash:LocalModelId` | `PYRODASH_LOCAL_MODEL` | `pyrodash-4b` | 本机模型标识;客户端未点名(或点名了本机没有的模型)时用它 | | `PyroDash:LocalModels` | `PYRODASH_LOCAL_MODELS`(逗号分隔) | 空(= 只有 `LocalModelId` 一个) | 本机端点**同时提供**的模型名白名单。客户端请求里的 `model` 命中白名单才原样转发给本机(router 据此选 checkpoint),未命中回落 `LocalModelId`。该列表同时决定 `GET /v1/models` 里出现的本机模型 | | `PyroDash:LocalVisionEnabled` | `PYRODASH_LOCAL_VISION` | `false` | 本机模型是否具备视觉能力;`false` 时含图片的请求跳过本地阶段直接走外部 | | `PyroDash:LocalOffloadHintEnabled` | `PYRODASH_LOCAL_OFFLOAD_HINT_ENABLED` | `true` | 是否给**本地**请求补上「你可以输出 `<\|llm_offload\|>` 求助」的系统提示词。客户端已自带则自动跳过;该提示词**绝不会**进入给外部大模型的请求 | | `PyroDash:LocalOffloadHint` | `PYRODASH_LOCAL_OFFLOAD_HINT` | 官方英文原文 | 自定义提示词文本(例如想换成中文) | | `PyroDash:AdminToken` | `PYRODASH_ADMIN_TOKEN` | `null`(首次启动生成) | 管理接口令牌;**为空时管理接口全部拒绝** | | `PyroDash:GatewayKeys` | `PYRODASH_GATEWAY_KEYS` | `null`(首次启动生成一枚) | 网关密钥数组(环境变量形式为逗号分隔);**为空时放行所有请求(仅限本机开发)** | | `PyroDash:BalancePollSeconds` | `PYRODASH_BALANCE_POLL_SECONDS` | `300` | 余额轮询间隔,`0` 表示关闭 | | `PyroDash:DefaultMaxTokens` | `PYRODASH_DEFAULT_MAX_TOKENS` | `4096` | 未指定时的输出上限 | | `PyroDash:UpstreamTimeoutSeconds` | `PYRODASH_UPSTREAM_TIMEOUT_SECONDS` | `120` | 上游非流式请求超时 | | `PyroDash:Media:MaxImagesPerRequest` | `PYRODASH_MAX_IMAGES` | `20` | 单请求图片张数上限 | | `PyroDash:Media:MaxImageBytes` | `PYRODASH_MAX_IMAGE_BYTES` | `5242880` | 单张图片体积上限(字节) | | `PyroDash:Media:AllowRemoteImageUrls` | `PYRODASH_ALLOW_REMOTE_IMAGE_URLS` | `false` | 是否允许远程图片 URL(默认拒绝,防 SSRF) | | ——(不在文件里,只有环境变量) | `PYRODASH_DISABLE_TOKEN_PROVISIONING` | `false` | 逃生开关:置真时跳过首次启动的凭据生成,且不写任何文件 | ## 开发与验收 ```bash source scripts/dev-env.sh # .NET SDK 装在 ~/.dotnet,刻意不在 PATH dotnet build -c Release # 必须 0 警告 0 错误 dotnet test -c Release # 必须全绿 ./scripts/publish-aot.sh linux-x64 # 唯一发布入口(前端门禁 + 体积门禁 + 校验和) ./scripts/smoke-e2e.sh linux-x64 # 13 项端到端验收(假上游 + 真实 AOT 产物) ``` ## 文档 - 方案原文转写:`docs/分享内容-转写.md` - 设计总览与决策记录:`docs/superpowers/plans/2026-09-15-pyrodash-00-overview.md` - 实施计划:`docs/superpowers/plans/2026-09-15-pyrodash-0{1,2,3}-*.md`